From 88e36fc3319fc6a5e412e1820a4421ec22e7d760 Mon Sep 17 00:00:00 2001 From: Alexander Irion Date: Wed, 12 Mar 2025 14:04:00 +0100 Subject: [PATCH 01/56] Add parameter disableHostkeyVerification When disableHostkeyVerification is set, the verification which is time consuming in debug mode is omitted. --- lib/src/ssh_client.dart | 5 +++++ lib/src/ssh_transport.dart | 18 ++++++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index d3f8b74..9290a1f 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -130,6 +130,9 @@ class SSHClient { /// extension. May not be called if the server does not support the extension. // final SSHHostKeysHandler? onHostKeys; + /// Allow to disable hostkey verification, which can be slow in debug mode. + final bool disableHostkeyVerification; + /// A [Future] that completes when the transport is closed, or when an error /// occurs. After this [Future] completes, [isClosed] will be true and no more /// data can be sent or received. @@ -152,6 +155,7 @@ class SSHClient { this.onUserauthBanner, this.onAuthenticated, this.keepAliveInterval = const Duration(seconds: 10), + this.disableHostkeyVerification = false, }) { _transport = SSHTransport( socket, @@ -162,6 +166,7 @@ class SSHClient { onVerifyHostKey: onVerifyHostKey, onReady: _handleTransportReady, onPacket: _handlePacket, + disableHostkeyVerification: disableHostkeyVerification, ); _transport.done.then( diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index 03540b6..3da0e79 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -72,6 +72,8 @@ class SSHTransport { /// Function called when a packet is received. final SSHPacketHandler? onPacket; + final bool disableHostkeyVerification; + /// A [Future] that completes when the transport is closed, or when an error /// occurs. After this [Future] completes, [isClosed] will be true and no /// more data can be sent or received. @@ -94,6 +96,7 @@ class SSHTransport { this.onVerifyHostKey, this.onReady, this.onPacket, + this.disableHostkeyVerification = false, }) { _initSocket(); _startHandshake(); @@ -803,12 +806,15 @@ class SSHTransport { sharedSecret: sharedSecret, ); - final verified = _verifyHostkey( - keyBytes: hostkey, - signatureBytes: hostSignature, - exchangeHash: exchangeHash, - ); - if (!verified) throw SSHHostkeyError('Signature verification failed'); + if (!disableHostkeyVerification) + { + final verified = _verifyHostkey( + keyBytes: hostkey, + signatureBytes: hostSignature, + exchangeHash: exchangeHash, + ); + if (!verified) throw SSHHostkeyError('Signature verification failed'); + } _exchangeHash = exchangeHash; _sessionId ??= exchangeHash; From e32b1326cdbbb35bfcf3b7185fdcc258a55516e9 Mon Sep 17 00:00:00 2001 From: Alexander Irion Date: Wed, 26 Mar 2025 16:28:26 +0100 Subject: [PATCH 02/56] Catch exceptions when sshd terminated When a sftp session is created with Client:sftp and on client side the sshd is killed it caused unhandled exceptions. --- lib/src/ssh_channel.dart | 10 +++++++--- lib/src/ssh_client.dart | 7 ++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/src/ssh_channel.dart b/lib/src/ssh_channel.dart index fb5daa7..f5defbf 100644 --- a/lib/src/ssh_channel.dart +++ b/lib/src/ssh_channel.dart @@ -297,7 +297,12 @@ class SSHChannelController { if (_done.isCompleted) return; if (_hasSentClose) return; _hasSentClose = true; - sendMessage(SSH_Message_Channel_Close(recipientChannel: remoteId)); + + try { + sendMessage(SSH_Message_Channel_Close(recipientChannel: remoteId)); + } catch (e) { + printDebug?.call('SSHChannelController._sendCloseIfNeeded - error: $e'); + } } void _sendRequestSuccess() { @@ -455,8 +460,7 @@ class SSHChannelExtendedDataType { static const stderr = 1; } -class SSHChannelDataSplitter - extends StreamTransformerBase { +class SSHChannelDataSplitter extends StreamTransformerBase { SSHChannelDataSplitter(this.maxSize); final int maxSize; diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index 9290a1f..ee841f5 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -481,7 +481,12 @@ class SSHClient { ); } _keepAlive?.stop(); - _closeChannels(); + + try { + _closeChannels(); + } catch (e) { + printDebug?.call("SSHClient::_handleTransportClosed - error: $e"); + } } void _handlePacket(Uint8List payload) { From b0fdd018bcb4ee98bd2182977a50ab5264003ef5 Mon Sep 17 00:00:00 2001 From: Bar Mazuz Date: Thu, 15 May 2025 20:12:23 +0300 Subject: [PATCH 03/56] Implemented support for server rekey (by default every 1 GB or 1 hour) --- lib/src/ssh_transport.dart | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index 03540b6..aaad760 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -174,6 +174,13 @@ class SSHTransport { final _remotePacketSN = SSHPacketSN.fromZero(); + /// Whether a key exchange is currently in progress (initial or re-key). + bool _kexInProgress = false; + + /// Whether we have already sent our SSH_MSG_KEXINIT for the ongoing key + /// exchange round. This is reset when the exchange finishes. + bool _sentKexInit = false; + void sendPacket(Uint8List data) { if (isClosed) { throw SSHStateError('Transport is closed'); @@ -553,6 +560,16 @@ class SSHTransport { void _sendKexInit() { printDebug?.call('SSHTransport._sendKexInit'); + // Don't start a new key exchange when one is already in progress + if (_kexInProgress && _sentKexInit) { + printDebug?.call('Key exchange already in progress, ignoring'); + return; + } + + // Mark that a new key-exchange round has started from our side. + _kexInProgress = true; + _sentKexInit = true; + final message = SSH_Message_KexInit( kexAlgorithms: algorithms.kex.toNameList(), // kexAlgorithms: ['curve25519-sha256'], @@ -646,6 +663,18 @@ class SSHTransport { void _handleMessageKexInit(Uint8List payload) { printDebug?.call('SSHTransport._handleMessageKexInit'); + // If this message initiates a new key-exchange round from the remote + // side, we MUST respond with our own KEXINIT (RFC 4253 §7.1). + if (!_kexInProgress) { + // Start a new exchange initiated by the peer. + _kexInProgress = true; + } + + if (!_sentKexInit) { + // We have not sent our KEXINIT for this round yet, do it now. + _sendKexInit(); + } + final message = SSH_Message_KexInit.decode(payload); printTrace?.call('<- $socket: $message'); _remoteKexInit = payload; @@ -857,6 +886,24 @@ class SSHTransport { void _handleMessageNewKeys(Uint8List message) { printDebug?.call('SSHTransport._handleMessageNewKeys'); printTrace?.call('<- $socket: SSH_Message_NewKeys'); + _applyRemoteKeys(); + + // Key exchange round finished. + _kexInProgress = false; + _sentKexInit = false; + _kex = null; + } + + /// Initiates a client-side re-key operation. This can be called + /// by client code to refresh session keys when needed. + void rekey() { + printDebug?.call('SSHTransport.rekey'); + if (_kexInProgress) { + printDebug + ?.call('Key exchange already in progress, ignoring rekey request'); + return; + } + _sendKexInit(); } } From 86805cb31af61cebd67ecf2977e2341b04a3ec32 Mon Sep 17 00:00:00 2001 From: Bar Mazuz Date: Thu, 15 May 2025 20:31:42 +0300 Subject: [PATCH 04/56] According to RFC, we shouldn't send anything except KEX stuff when doing rekey --- lib/src/ssh_transport.dart | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index aaad760..a418d65 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -181,10 +181,19 @@ class SSHTransport { /// exchange round. This is reset when the exchange finishes. bool _sentKexInit = false; + /// Packets queued during key exchange that will be sent after NEW_KEYS + final List _rekeyPendingPackets = []; + void sendPacket(Uint8List data) { if (isClosed) { throw SSHStateError('Transport is closed'); } + + if (_kexInProgress && !_isKexPacket(data)) { + _rekeyPendingPackets.add(Uint8List.fromList(data)); + return; + } + final packetAlign = _encryptCipher == null ? SSHPacket.minAlign : max(SSHPacket.minAlign, _encryptCipher!.blockSize); @@ -893,6 +902,13 @@ class SSHTransport { _kexInProgress = false; _sentKexInit = false; _kex = null; + + // Flush any pending packets + final pending = List.from(_rekeyPendingPackets); + _rekeyPendingPackets.clear(); + for (final packet in pending) { + sendPacket(packet); + } } /// Initiates a client-side re-key operation. This can be called @@ -906,4 +922,20 @@ class SSHTransport { } _sendKexInit(); } + + bool _isKexPacket(Uint8List data) { + if (data.isEmpty) return false; + + // All KEX message IDs + const kexMsgIds = [ + SSH_Message_KexInit.messageId, + SSH_Message_NewKeys.messageId, + SSH_Message_KexDH_Init.messageId, + SSH_Message_KexDH_Reply.messageId, + SSH_Message_KexDH_GexRequest.messageId, + SSH_Message_KexDH_GexGroup.messageId, + SSH_Message_KexDH_GexInit.messageId, + ]; + return kexMsgIds.contains(data[0]); + } } From dcce7af7a2605d6665d9d9d2e07b11d0d7fa7780 Mon Sep 17 00:00:00 2001 From: Bar Mazuz Date: Thu, 15 May 2025 20:52:10 +0300 Subject: [PATCH 05/56] Improved function to allow critical ssh messages through as well changed logs --- lib/src/ssh_transport.dart | 41 ++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index a418d65..fd3933d 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -189,7 +189,7 @@ class SSHTransport { throw SSHStateError('Transport is closed'); } - if (_kexInProgress && !_isKexPacket(data)) { + if (_kexInProgress && !_shouldBypassRekeyBuffer(data)) { _rekeyPendingPackets.add(Uint8List.fromList(data)); return; } @@ -923,19 +923,34 @@ class SSHTransport { _sendKexInit(); } - bool _isKexPacket(Uint8List data) { + /// Determines if a packet should bypass the rekey buffer. + /// + /// During key exchange, most packets should be buffered until the exchange + /// is complete. However, key exchange packets themselves and transport layer + /// control messages (like disconnect) need to be sent immediately. + /// + /// Per RFC 4253, the following message types bypass the buffer: + /// + /// /// Critical transport messages (1-4): + /// - 1: [SSH_Message_Disconnect] + /// - 2: [SSH_Message_Ignore] + /// - 3: [SSH_Message_Unimplemented] + /// - 4: [SSH_Message_Debug] + /// + /// Key exchange messages (20-49): + /// - 20: [SSH_Message_KexInit] + /// - 21: [SSH_Message_NewKeys] + /// - 30: [SSH_Message_KexDH_Init]/[SSH_Message_KexECDH_Init] + /// - 31: [SSH_Message_KexDH_Reply]/[SSH_Message_KexECDH_Reply]/[SSH_Message_KexDH_GexGroup] + /// - 32: [SSH_Message_KexDH_GexInit] + /// - 33: [SSH_Message_KexDH_GexReply] + /// - 34: [SSH_Message_KexDH_GexRequest] + /// + /// + bool _shouldBypassRekeyBuffer(Uint8List data) { if (data.isEmpty) return false; - // All KEX message IDs - const kexMsgIds = [ - SSH_Message_KexInit.messageId, - SSH_Message_NewKeys.messageId, - SSH_Message_KexDH_Init.messageId, - SSH_Message_KexDH_Reply.messageId, - SSH_Message_KexDH_GexRequest.messageId, - SSH_Message_KexDH_GexGroup.messageId, - SSH_Message_KexDH_GexInit.messageId, - ]; - return kexMsgIds.contains(data[0]); + final messageId = data[0]; + return (messageId >= 20 && messageId <= 49) || messageId <= 4; } } From bead8a869708f8cdccf9ba6e78f17f80dfc011eb Mon Sep 17 00:00:00 2001 From: brein Date: Wed, 18 Jun 2025 16:50:33 +0200 Subject: [PATCH 06/56] Add support for new MAC mac-sha2-256-96", "hmac-sha2-512-96", "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com" algorithms --- lib/src/algorithm/ssh_mac_type.dart | 39 +++++++++++++-- lib/src/ssh_algorithm.dart | 6 +++ lib/src/utils/truncated_HMac.dart | 26 ++++++++++ test/src/algorithm/ssh_cipher_type_test.dart | 4 ++ test/src/algorithm/ssh_mac_type_test.dart | 50 ++++++++++++++++++++ 5 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 lib/src/utils/truncated_HMac.dart diff --git a/lib/src/algorithm/ssh_mac_type.dart b/lib/src/algorithm/ssh_mac_type.dart index 0d1f59b..645a093 100644 --- a/lib/src/algorithm/ssh_mac_type.dart +++ b/lib/src/algorithm/ssh_mac_type.dart @@ -3,6 +3,8 @@ import 'dart:typed_data'; import 'package:dartssh2/src/ssh_algorithm.dart'; import 'package:pointycastle/export.dart'; +import '../utils/truncated_HMac.dart'; + class SSHMacType with SSHAlgorithm { static const hmacMd5 = SSHMacType._( name: 'hmac-md5', @@ -27,19 +29,41 @@ class SSHMacType with SSHAlgorithm { keySize: 64, macFactory: _hmacSha512Factory, ); + + // added by Rein + static const hmacSha256_96 = SSHMacType._( + name: 'hmac-sha2-256-96', + keySize: 32, + macFactory: _hmacSha256_96Factory, + ); + + static const hmacSha512_96 = SSHMacType._( + name: 'hmac-sha2-512-96', + keySize: 64, + macFactory: _hmacSha512_96Factory, + ); + + static const hmacSha256Etm = SSHMacType._( + name: 'hmac-sha2-256-etm@openssh.com', + keySize: 32, + macFactory: _hmacSha256Factory, + ); + static const hmacSha512Etm = SSHMacType._( + name: 'hmac-sha2-512-etm@openssh.com', + keySize: 64, + macFactory: _hmacSha512Factory, + ); + // end added by Rein const SSHMacType._({ required this.name, required this.keySize, required this.macFactory, }); - /// The name of the algorithm. For example, `"aes256-ctr`"`. @override final String name; - /// The length of the key in bytes. This is the same as the length of the - /// output of the MAC algorithm. final int keySize; final Mac Function() macFactory; @@ -70,3 +94,12 @@ Mac _hmacSha256Factory() { Mac _hmacSha512Factory() { return HMac(SHA512Digest(), 128); } +// added by Rein +Mac _hmacSha256_96Factory() { + return TruncatedHMac(SHA256Digest(), 64, 12); +} + +Mac _hmacSha512_96Factory() { + return TruncatedHMac(SHA512Digest(), 128, 12); +} +//end added by Rein \ No newline at end of file diff --git a/lib/src/ssh_algorithm.dart b/lib/src/ssh_algorithm.dart index 35d3c7d..e0ef706 100644 --- a/lib/src/ssh_algorithm.dart +++ b/lib/src/ssh_algorithm.dart @@ -69,6 +69,12 @@ class SSHAlgorithms { SSHCipherType.aes256cbc, ], this.mac = const [ + // added by Rein + SSHMacType.hmacSha256_96, + SSHMacType.hmacSha512_96, + SSHMacType.hmacSha256Etm, + SSHMacType.hmacSha512Etm, + // end added by Rein SSHMacType.hmacSha1, SSHMacType.hmacSha256, SSHMacType.hmacSha512, diff --git a/lib/src/utils/truncated_HMac.dart b/lib/src/utils/truncated_HMac.dart new file mode 100644 index 0000000..0565255 --- /dev/null +++ b/lib/src/utils/truncated_HMac.dart @@ -0,0 +1,26 @@ +import 'dart:typed_data'; + +import 'package:pointycastle/macs/hmac.dart'; + +/// Custom HMac implementation that truncates output to 96 bits (12 bytes) +class TruncatedHMac extends HMac { + final int _truncatedSize; + + TruncatedHMac(super.digest, super.blockSize, this._truncatedSize); + + @override + int get macSize => _truncatedSize; // 12 bytes instead of 32/64 + + @override + int doFinal(Uint8List out, int outOff) { + // Call the original doFinal to get the full MAC + final fullMacSize = super.macSize; + final tempBuffer = Uint8List(fullMacSize); + super.doFinal(tempBuffer, 0); + + // Copy only the first 12 bytes to the output + out.setRange(outOff, outOff + _truncatedSize, tempBuffer); + + return _truncatedSize; + } +} \ No newline at end of file diff --git a/test/src/algorithm/ssh_cipher_type_test.dart b/test/src/algorithm/ssh_cipher_type_test.dart index 15ae2fd..45019c3 100644 --- a/test/src/algorithm/ssh_cipher_type_test.dart +++ b/test/src/algorithm/ssh_cipher_type_test.dart @@ -81,6 +81,10 @@ void main() { expect( algorithms.mac, equals([ + SSHMacType.hmacSha256_96, + SSHMacType.hmacSha512_96, + SSHMacType.hmacSha256Etm, + SSHMacType.hmacSha512Etm, SSHMacType.hmacSha1, SSHMacType.hmacSha256, SSHMacType.hmacSha512, diff --git a/test/src/algorithm/ssh_mac_type_test.dart b/test/src/algorithm/ssh_mac_type_test.dart index a1b14c0..b75ca58 100644 --- a/test/src/algorithm/ssh_mac_type_test.dart +++ b/test/src/algorithm/ssh_mac_type_test.dart @@ -11,11 +11,21 @@ void main() { expect(SSHMacType.hmacSha1.name, equals('hmac-sha1')); expect(SSHMacType.hmacSha256.name, equals('hmac-sha2-256')); expect(SSHMacType.hmacSha512.name, equals('hmac-sha2-512')); + // Added new algorithms + expect(SSHMacType.hmacSha256_96.name, equals('hmac-sha2-256-96')); + expect(SSHMacType.hmacSha512_96.name, equals('hmac-sha2-512-96')); + expect(SSHMacType.hmacSha256Etm.name, equals('hmac-sha2-256-etm@openssh.com')); + expect(SSHMacType.hmacSha512Etm.name, equals('hmac-sha2-512-etm@openssh.com')); expect(SSHMacType.hmacMd5.keySize, equals(16)); expect(SSHMacType.hmacSha1.keySize, equals(20)); expect(SSHMacType.hmacSha256.keySize, equals(32)); expect(SSHMacType.hmacSha512.keySize, equals(64)); + // Added new algorithm key sizes + expect(SSHMacType.hmacSha256_96.keySize, equals(32)); + expect(SSHMacType.hmacSha512_96.keySize, equals(64)); + expect(SSHMacType.hmacSha256Etm.keySize, equals(32)); + expect(SSHMacType.hmacSha512Etm.keySize, equals(64)); }); test('createMac() returns correct Mac instance', () { @@ -49,5 +59,45 @@ void main() { final mac = SSHMacType.hmacSha512.createMac(key); expect(mac, isA()); }); + + test('createMac() for new algorithm types returns correct instances', () { + final sha256Key = Uint8List(32); // 32 bytes for SHA-256 based algorithms + final sha512Key = Uint8List(64); // 64 bytes for SHA-512 based algorithms + + final macSha256_96 = SSHMacType.hmacSha256_96.createMac(sha256Key); + final macSha512_96 = SSHMacType.hmacSha512_96.createMac(sha512Key); + final macSha256Etm = SSHMacType.hmacSha256Etm.createMac(sha256Key); + final macSha512Etm = SSHMacType.hmacSha512Etm.createMac(sha512Key); + + expect(macSha256_96, isNotNull); + expect(macSha512_96, isNotNull); + expect(macSha256Etm, isA()); + expect(macSha512Etm, isA()); + }); + + test('createMac() throws for new algorithms with incorrect key length', () { + final shortSha256Key = Uint8List(31); // One byte too short for SHA-256 based algorithms + final shortSha512Key = Uint8List(63); // One byte too short for SHA-512 based algorithms + + expect( + () => SSHMacType.hmacSha256_96.createMac(shortSha256Key), + throwsArgumentError, + ); + + expect( + () => SSHMacType.hmacSha512_96.createMac(shortSha512Key), + throwsArgumentError, + ); + + expect( + () => SSHMacType.hmacSha256Etm.createMac(shortSha256Key), + throwsArgumentError, + ); + + expect( + () => SSHMacType.hmacSha512Etm.createMac(shortSha512Key), + throwsArgumentError, + ); + }); }); } From 81c6905f4305e7a0c3be4a6a3d2cde6033d77c1e Mon Sep 17 00:00:00 2001 From: brein Date: Fri, 20 Jun 2025 01:26:34 +0200 Subject: [PATCH 07/56] Corrected the ETM encryptions --- lib/src/utils/{truncated_HMac.dart => truncated_hmac.dart} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/src/utils/{truncated_HMac.dart => truncated_hmac.dart} (100%) diff --git a/lib/src/utils/truncated_HMac.dart b/lib/src/utils/truncated_hmac.dart similarity index 100% rename from lib/src/utils/truncated_HMac.dart rename to lib/src/utils/truncated_hmac.dart From c55e2692ad65fad49c86e2b6483c2c1ccede38d9 Mon Sep 17 00:00:00 2001 From: brein Date: Fri, 20 Jun 2025 01:27:12 +0200 Subject: [PATCH 08/56] Corrected the ETM encryptions --- lib/src/algorithm/ssh_cipher_type.dart | 2 +- lib/src/algorithm/ssh_hostkey_type.dart | 2 +- lib/src/algorithm/ssh_kex_type.dart | 2 +- lib/src/algorithm/ssh_mac_type.dart | 15 +- lib/src/ssh_algorithm.dart | 4 +- lib/src/ssh_transport.dart | 231 ++++++++++++++++++---- test/src/algorithm/ssh_mac_type_test.dart | 56 ++---- 7 files changed, 225 insertions(+), 87 deletions(-) diff --git a/lib/src/algorithm/ssh_cipher_type.dart b/lib/src/algorithm/ssh_cipher_type.dart index 2553ca6..c59db33 100644 --- a/lib/src/algorithm/ssh_cipher_type.dart +++ b/lib/src/algorithm/ssh_cipher_type.dart @@ -3,7 +3,7 @@ import 'dart:typed_data'; import 'package:dartssh2/src/ssh_algorithm.dart'; import 'package:pointycastle/export.dart'; -class SSHCipherType with SSHAlgorithm { +class SSHCipherType extends SSHAlgorithm { static const values = [ aes128cbc, aes192cbc, diff --git a/lib/src/algorithm/ssh_hostkey_type.dart b/lib/src/algorithm/ssh_hostkey_type.dart index 307ef7d..513c800 100644 --- a/lib/src/algorithm/ssh_hostkey_type.dart +++ b/lib/src/algorithm/ssh_hostkey_type.dart @@ -1,6 +1,6 @@ import 'package:dartssh2/src/ssh_algorithm.dart'; -class SSHHostkeyType with SSHAlgorithm { +class SSHHostkeyType extends SSHAlgorithm { static const rsaSha1 = SSHHostkeyType._('ssh-rsa'); static const rsaSha256 = SSHHostkeyType._('rsa-sha2-256'); static const rsaSha512 = SSHHostkeyType._('rsa-sha2-512'); diff --git a/lib/src/algorithm/ssh_kex_type.dart b/lib/src/algorithm/ssh_kex_type.dart index 9110230..2866319 100644 --- a/lib/src/algorithm/ssh_kex_type.dart +++ b/lib/src/algorithm/ssh_kex_type.dart @@ -1,7 +1,7 @@ import 'package:dartssh2/src/ssh_algorithm.dart'; import 'package:pointycastle/export.dart'; -class SSHKexType with SSHAlgorithm { +class SSHKexType extends SSHAlgorithm { static const x25519 = SSHKexType._( name: 'curve25519-sha256@libssh.org', digestFactory: digestSha256, diff --git a/lib/src/algorithm/ssh_mac_type.dart b/lib/src/algorithm/ssh_mac_type.dart index 645a093..6e00d75 100644 --- a/lib/src/algorithm/ssh_mac_type.dart +++ b/lib/src/algorithm/ssh_mac_type.dart @@ -3,9 +3,9 @@ import 'dart:typed_data'; import 'package:dartssh2/src/ssh_algorithm.dart'; import 'package:pointycastle/export.dart'; -import '../utils/truncated_HMac.dart'; +import '../utils/truncated_hmac.dart'; -class SSHMacType with SSHAlgorithm { +class SSHMacType extends SSHAlgorithm { static const hmacMd5 = SSHMacType._( name: 'hmac-md5', keySize: 16, @@ -29,7 +29,7 @@ class SSHMacType with SSHAlgorithm { keySize: 64, macFactory: _hmacSha512Factory, ); - + // added by Rein static const hmacSha256_96 = SSHMacType._( name: 'hmac-sha2-256-96', @@ -47,18 +47,21 @@ class SSHMacType with SSHAlgorithm { name: 'hmac-sha2-256-etm@openssh.com', keySize: 32, macFactory: _hmacSha256Factory, + isEtm: true, ); static const hmacSha512Etm = SSHMacType._( name: 'hmac-sha2-512-etm@openssh.com', keySize: 64, macFactory: _hmacSha512Factory, + isEtm: true, ); // end added by Rein const SSHMacType._({ required this.name, required this.keySize, required this.macFactory, + this.isEtm = false, }); @override @@ -68,6 +71,9 @@ class SSHMacType with SSHAlgorithm { final Mac Function() macFactory; + /// Whether this MAC algorithm is an ETM (Encrypt-Then-MAC) variant. + final bool isEtm; + Mac createMac(Uint8List key) { if (key.length != keySize) { throw ArgumentError.value(key, 'key', 'Key must be $keySize bytes long'); @@ -94,7 +100,7 @@ Mac _hmacSha256Factory() { Mac _hmacSha512Factory() { return HMac(SHA512Digest(), 128); } -// added by Rein + Mac _hmacSha256_96Factory() { return TruncatedHMac(SHA256Digest(), 64, 12); } @@ -102,4 +108,3 @@ Mac _hmacSha256_96Factory() { Mac _hmacSha512_96Factory() { return TruncatedHMac(SHA512Digest(), 128, 12); } -//end added by Rein \ No newline at end of file diff --git a/lib/src/ssh_algorithm.dart b/lib/src/ssh_algorithm.dart index e0ef706..fc57995 100644 --- a/lib/src/ssh_algorithm.dart +++ b/lib/src/ssh_algorithm.dart @@ -7,6 +7,8 @@ abstract class SSHAlgorithm { /// The name of the algorithm. String get name; + const SSHAlgorithm(); + @override String toString() { return '$runtimeType($name)'; @@ -69,12 +71,10 @@ class SSHAlgorithms { SSHCipherType.aes256cbc, ], this.mac = const [ - // added by Rein SSHMacType.hmacSha256_96, SSHMacType.hmacSha512_96, SSHMacType.hmacSha256Etm, SSHMacType.hmacSha512Etm, - // end added by Rein SSHMacType.hmacSha1, SSHMacType.hmacSha256, SSHMacType.hmacSha512, diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index 03540b6..3589a1d 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -178,24 +178,95 @@ class SSHTransport { if (isClosed) { throw SSHStateError('Transport is closed'); } - final packetAlign = _encryptCipher == null - ? SSHPacket.minAlign - : max(SSHPacket.minAlign, _encryptCipher!.blockSize); - final packet = SSHPacket.pack(data, align: packetAlign); + // Check if encryption is enabled and if we have MAC types initialized + final clientMacType = _clientMacType; + final serverMacType = _serverMacType; + final macType = isClient ? clientMacType : serverMacType; + final isEtm = _encryptCipher != null && macType != null && macType.isEtm; + + // For ETM, we need to handle the packet differently + if (isEtm) { + // For ETM (Encrypt-Then-MAC): + // 1. Keep the packet length in plaintext + // 2. Encrypt only the payload (padding length, payload, padding) + + // Calculate the block size for alignment + final blockSize = _encryptCipher!.blockSize; + + // Create a custom packet structure for ETM mode + // We need to ensure that the payload we're encrypting is a multiple of the block size + + // Calculate the padding length to ensure the total length is a multiple of the block size + // We need to account for the 1 byte padding length field + final paddingLength = blockSize - ((data.length + 1) % blockSize); + // Ensure padding is at least 4 bytes as per SSH spec + final adjustedPaddingLength = paddingLength < 4 ? paddingLength + blockSize : paddingLength; + + // Calculate the total packet length (excluding the length field itself) + final packetLength = 1 + data.length + adjustedPaddingLength; + + // Create the packet length field (4 bytes) + final packetLengthBytes = Uint8List(4); + packetLengthBytes.buffer.asByteData().setUint32(0, packetLength); + + // Create the payload to be encrypted (padding length + payload + padding) + final payloadToEncrypt = Uint8List(packetLength); + payloadToEncrypt[0] = adjustedPaddingLength; // Set padding length + payloadToEncrypt.setRange(1, 1 + data.length, data); // Copy data + + // Add random padding + for (var i = 0; i < adjustedPaddingLength; i++) { + payloadToEncrypt[1 + data.length + i] = (DateTime.now().microsecondsSinceEpoch + i) & 0xFF; + } - if (_encryptCipher == null) { - socket.sink.add(packet); - } else { + // Verify that the payload length is a multiple of the block size + if (payloadToEncrypt.length % blockSize != 0) { + throw StateError('Payload length ${payloadToEncrypt.length} is not a multiple of block size $blockSize'); + } + + // Encrypt the payload + final encryptedPayload = _encryptCipher!.processAll(payloadToEncrypt); + + // Calculate MAC on the packet length and encrypted payload final mac = _localMac!; mac.updateAll(_localPacketSN.value.toUint32()); - mac.updateAll(packet); + mac.updateAll(packetLengthBytes); + mac.updateAll(encryptedPayload); + final macBytes = mac.finish(); + // Build the final packet: length + encrypted payload + MAC final buffer = BytesBuilder(copy: false); - buffer.add(_encryptCipher!.processAll(packet)); - buffer.add(mac.finish()); + buffer.add(packetLengthBytes); + buffer.add(encryptedPayload); + buffer.add(macBytes); socket.sink.add(buffer.takeBytes()); + } else { + // For standard encryption or no encryption: + // Use the original packet packing logic + final packetAlign = _encryptCipher == null + ? SSHPacket.minAlign + : max(SSHPacket.minAlign, _encryptCipher!.blockSize); + + final packet = SSHPacket.pack(data, align: packetAlign); + + if (_encryptCipher == null) { + socket.sink.add(packet); + } else { + final mac = _localMac!; + final encryptedPacket = _encryptCipher!.processAll(packet); + + final buffer = BytesBuilder(copy: false); + buffer.add(encryptedPacket); + + // Calculate MAC on the unencrypted packet + mac.updateAll(_localPacketSN.value.toUint32()); + mac.updateAll(packet); + buffer.add(mac.finish()); + + socket.sink.add(buffer.takeBytes()); + } } _localPacketSN.increase(); @@ -357,33 +428,109 @@ class SSHTransport { return null; } - if (_decryptBuffer.isEmpty) { - final firstBlock = _buffer.consume(blockSize); - _decryptBuffer.add(_decryptCipher!.process(firstBlock)); - } + final macType = isClient ? _serverMacType! : _clientMacType!; + final isEtm = macType.isEtm; + final macLength = _remoteMac!.macSize; - final packetLength = SSHPacket.readPacketLength(_decryptBuffer.data); - _verifyPacketLength(packetLength); + if (isEtm) { + // For ETM (Encrypt-Then-MAC) algorithms, the packet length is in plaintext + // followed by the encrypted payload and then the MAC - final macLength = _remoteMac!.macSize; - if (_buffer.length + _decryptBuffer.length < 4 + packetLength + macLength) { - return null; - } + // We need at least 4 bytes to read the packet length + if (_buffer.length < 4) { + return null; + } - while (_decryptBuffer.length < 4 + packetLength) { - final block = _buffer.consume(blockSize); - _decryptBuffer.add(_decryptCipher!.process(block)); - } + // Read the packet length from the plaintext data + final packetLength = SSHPacket.readPacketLength(_buffer.data); + _verifyPacketLength(packetLength); - final packet = _decryptBuffer.consume(packetLength + 4); - final paddingLength = SSHPacket.readPaddingLength(packet); - final payloadLength = packetLength - paddingLength - 1; - _verifyPacketPadding(payloadLength, paddingLength); + // Make sure we have enough data for the entire packet and MAC + if (_buffer.length < 4 + packetLength + macLength) { + return null; + } - final mac = _buffer.consume(macLength); - _verifyPacketMac(packet, mac); + // Get the packet length bytes + final packetLengthBytes = _buffer.view(0, 4); - return Uint8List.sublistView(packet, 5, packet.length - paddingLength); + // Get the encrypted payload and MAC + final encryptedPayload = _buffer.view(4, packetLength); + final mac = _buffer.view(4 + packetLength, macLength); + + // Verify the MAC on the packet length and encrypted payload + final packetForMac = Uint8List(4 + packetLength); + packetForMac.setRange(0, 4, packetLengthBytes); + packetForMac.setRange(4, 4 + packetLength, encryptedPayload); + _verifyPacketMac(packetForMac, mac, isEncrypted: true); + + // Consume the packet and MAC from the buffer + _buffer.consume(4 + packetLength + macLength); + + // Ensure the encrypted payload length is a multiple of the block size + if (encryptedPayload.length % blockSize != 0) { + throw SSHPacketError( + 'Encrypted payload length ${encryptedPayload.length} is not a multiple of block size $blockSize', + ); + } + + // Decrypt the payload + final decryptedPayload = _decryptCipher!.processAll(encryptedPayload); + + // Process the decrypted payload + final paddingLength = decryptedPayload[0]; + + // Verify that the padding length is valid + if (paddingLength < 4) { + throw SSHPacketError( + 'Padding length too small: $paddingLength (minimum is 4)', + ); + } + + if (paddingLength >= packetLength) { + throw SSHPacketError( + 'Padding length too large: $paddingLength (packet length is $packetLength)', + ); + } + + final payloadLength = packetLength - paddingLength - 1; + if (payloadLength < 0) { + throw SSHPacketError( + 'Invalid payload length: $payloadLength (packet length: $packetLength, padding length: $paddingLength)', + ); + } + + // Skip the padding length byte and extract the payload + return Uint8List.sublistView(decryptedPayload, 1, 1 + payloadLength); + } else { + // For standard MAC algorithms, decrypt the packet first, then verify the MAC + + if (_decryptBuffer.isEmpty) { + final firstBlock = _buffer.consume(blockSize); + _decryptBuffer.add(_decryptCipher!.process(firstBlock)); + } + + final packetLength = SSHPacket.readPacketLength(_decryptBuffer.data); + _verifyPacketLength(packetLength); + + if (_buffer.length + _decryptBuffer.length < 4 + packetLength + macLength) { + return null; + } + + while (_decryptBuffer.length < 4 + packetLength) { + final block = _buffer.consume(blockSize); + _decryptBuffer.add(_decryptCipher!.process(block)); + } + + final packet = _decryptBuffer.consume(packetLength + 4); + final paddingLength = SSHPacket.readPaddingLength(packet); + final payloadLength = packetLength - paddingLength - 1; + _verifyPacketPadding(payloadLength, paddingLength); + + final mac = _buffer.consume(macLength); + _verifyPacketMac(packet, mac, isEncrypted: false); + + return Uint8List.sublistView(packet, 5, packet.length - paddingLength); + } } void _verifyPacketLength(int packetLength) { @@ -413,14 +560,32 @@ class SSHTransport { /// Verifies that the MAC of the packet is correct. Throws [SSHPacketError] /// if the MAC is incorrect. - void _verifyPacketMac(Uint8List payload, Uint8List actualMac) { + /// + /// For ETM (Encrypt-Then-MAC) algorithms, the MAC is calculated on the packet length and encrypted payload. + /// For standard MAC algorithms, the MAC is calculated on the unencrypted packet. + void _verifyPacketMac(Uint8List payload, Uint8List actualMac, {bool isEncrypted = false}) { final macSize = _remoteMac!.macSize; if (actualMac.length != macSize) { throw ArgumentError.value(actualMac, 'mac', 'Invalid MAC size'); } + final macType = isClient ? _serverMacType! : _clientMacType!; + final isEtm = macType.isEtm; + _remoteMac!.updateAll(_remotePacketSN.value.toUint32()); - _remoteMac!.updateAll(payload); + + // For ETM algorithms, the MAC is calculated on the packet length and encrypted payload + // For standard MAC algorithms, the MAC is calculated on the unencrypted packet + if (isEtm && isEncrypted) { + _remoteMac!.updateAll(payload); + } else if (!isEtm && !isEncrypted) { + _remoteMac!.updateAll(payload); + } else { + throw SSHPacketError( + 'MAC algorithm mismatch: isEtm=$isEtm, isEncrypted=$isEncrypted', + ); + } + final expectedMac = _remoteMac!.finish(); if (!expectedMac.equals(actualMac)) { diff --git a/test/src/algorithm/ssh_mac_type_test.dart b/test/src/algorithm/ssh_mac_type_test.dart index b75ca58..a9c69ca 100644 --- a/test/src/algorithm/ssh_mac_type_test.dart +++ b/test/src/algorithm/ssh_mac_type_test.dart @@ -11,9 +11,6 @@ void main() { expect(SSHMacType.hmacSha1.name, equals('hmac-sha1')); expect(SSHMacType.hmacSha256.name, equals('hmac-sha2-256')); expect(SSHMacType.hmacSha512.name, equals('hmac-sha2-512')); - // Added new algorithms - expect(SSHMacType.hmacSha256_96.name, equals('hmac-sha2-256-96')); - expect(SSHMacType.hmacSha512_96.name, equals('hmac-sha2-512-96')); expect(SSHMacType.hmacSha256Etm.name, equals('hmac-sha2-256-etm@openssh.com')); expect(SSHMacType.hmacSha512Etm.name, equals('hmac-sha2-512-etm@openssh.com')); @@ -21,9 +18,6 @@ void main() { expect(SSHMacType.hmacSha1.keySize, equals(20)); expect(SSHMacType.hmacSha256.keySize, equals(32)); expect(SSHMacType.hmacSha512.keySize, equals(64)); - // Added new algorithm key sizes - expect(SSHMacType.hmacSha256_96.keySize, equals(32)); - expect(SSHMacType.hmacSha512_96.keySize, equals(64)); expect(SSHMacType.hmacSha256Etm.keySize, equals(32)); expect(SSHMacType.hmacSha512Etm.keySize, equals(64)); }); @@ -60,44 +54,18 @@ void main() { expect(mac, isA()); }); - test('createMac() for new algorithm types returns correct instances', () { - final sha256Key = Uint8List(32); // 32 bytes for SHA-256 based algorithms - final sha512Key = Uint8List(64); // 64 bytes for SHA-512 based algorithms - - final macSha256_96 = SSHMacType.hmacSha256_96.createMac(sha256Key); - final macSha512_96 = SSHMacType.hmacSha512_96.createMac(sha512Key); - final macSha256Etm = SSHMacType.hmacSha256Etm.createMac(sha256Key); - final macSha512Etm = SSHMacType.hmacSha512Etm.createMac(sha512Key); - - expect(macSha256_96, isNotNull); - expect(macSha512_96, isNotNull); - expect(macSha256Etm, isA()); - expect(macSha512Etm, isA()); - }); - - test('createMac() throws for new algorithms with incorrect key length', () { - final shortSha256Key = Uint8List(31); // One byte too short for SHA-256 based algorithms - final shortSha512Key = Uint8List(63); // One byte too short for SHA-512 based algorithms - - expect( - () => SSHMacType.hmacSha256_96.createMac(shortSha256Key), - throwsArgumentError, - ); - - expect( - () => SSHMacType.hmacSha512_96.createMac(shortSha512Key), - throwsArgumentError, - ); - - expect( - () => SSHMacType.hmacSha256Etm.createMac(shortSha256Key), - throwsArgumentError, - ); - - expect( - () => SSHMacType.hmacSha512Etm.createMac(shortSha512Key), - throwsArgumentError, - ); + test('isEtm property is set correctly', () { + // Non-ETM algorithms + expect(SSHMacType.hmacMd5.isEtm, isFalse); + expect(SSHMacType.hmacSha1.isEtm, isFalse); + expect(SSHMacType.hmacSha256.isEtm, isFalse); + expect(SSHMacType.hmacSha512.isEtm, isFalse); + expect(SSHMacType.hmacSha256_96.isEtm, isFalse); + expect(SSHMacType.hmacSha512_96.isEtm, isFalse); + + // ETM algorithms + expect(SSHMacType.hmacSha256Etm.isEtm, isTrue); + expect(SSHMacType.hmacSha512Etm.isEtm, isTrue); }); }); } From cd3bc7737d05474e96798fae5dc829de19d5c79b Mon Sep 17 00:00:00 2001 From: xuty Date: Sun, 22 Jun 2025 10:27:57 +0800 Subject: [PATCH 09/56] Format code --- lib/src/ssh_channel.dart | 3 ++- lib/src/ssh_transport.dart | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/ssh_channel.dart b/lib/src/ssh_channel.dart index f5defbf..20f0fa8 100644 --- a/lib/src/ssh_channel.dart +++ b/lib/src/ssh_channel.dart @@ -460,7 +460,8 @@ class SSHChannelExtendedDataType { static const stderr = 1; } -class SSHChannelDataSplitter extends StreamTransformerBase { +class SSHChannelDataSplitter + extends StreamTransformerBase { SSHChannelDataSplitter(this.maxSize); final int maxSize; diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index 3da0e79..b50ccf3 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -806,8 +806,7 @@ class SSHTransport { sharedSecret: sharedSecret, ); - if (!disableHostkeyVerification) - { + if (!disableHostkeyVerification) { final verified = _verifyHostkey( keyBytes: hostkey, signatureBytes: hostSignature, From 364856d3834001797b5b2fe04fabca465fc696d2 Mon Sep 17 00:00:00 2001 From: xuty Date: Sun, 22 Jun 2025 12:01:24 +0800 Subject: [PATCH 10/56] Add more tests --- test/src/ssh_client_test.dart | 32 ++++++++++++++++++++++++++++++++ test/test_utils.dart | 5 ++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/test/src/ssh_client_test.dart b/test/src/ssh_client_test.dart index 8a6abd8..ecd9189 100644 --- a/test/src/ssh_client_test.dart +++ b/test/src/ssh_client_test.dart @@ -26,6 +26,38 @@ void main() { // client.close(); // }); + // test('hmacSha256_96 mac works', () async { + // var client = await getHoneypotClient( + // algorithms: SSHAlgorithms(mac: [SSHMacType.hmacSha256_96]), + // ); + // await client.authenticated; + // client.close(); + // }); + + // test('hmacSha512_96 mac works', () async { + // var client = await getHoneypotClient( + // algorithms: SSHAlgorithms(mac: [SSHMacType.hmacSha512_96]), + // ); + // await client.authenticated; + // client.close(); + // }); + + test('hmacSha256Etm mac works', () async { + var client = await getHoneypotClient( + algorithms: SSHAlgorithms(mac: [SSHMacType.hmacSha256Etm]), + ); + await client.authenticated; + client.close(); + }); + + test('hmacSha512Etm mac works', () async { + var client = await getHoneypotClient( + algorithms: SSHAlgorithms(mac: [SSHMacType.hmacSha512Etm]), + ); + await client.authenticated; + client.close(); + }); + test('throws SSHAuthFailError when public key is wrong', () async { var client = SSHClient( await SSHSocket.connect('test.rebex.net', 22), diff --git a/test/test_utils.dart b/test/test_utils.dart index f4c96c0..328fe96 100644 --- a/test/test_utils.dart +++ b/test/test_utils.dart @@ -5,11 +5,14 @@ import 'package:dartssh2/dartssh2.dart'; import 'package:dartssh2/src/message/msg_channel.dart'; /// A honeypot that accepts all passwords and public-keys -Future getHoneypotClient() async { +Future getHoneypotClient({ + SSHAlgorithms algorithms = const SSHAlgorithms(), +}) async { return SSHClient( await SSHSocket.connect('test.rebex.net', 22), username: 'demo', onPasswordRequest: () => 'password', + algorithms: algorithms, ); } From 81cf1c543928f85fa5a70761ae3c93265506b711 Mon Sep 17 00:00:00 2001 From: xuty Date: Sun, 22 Jun 2025 14:43:59 +0800 Subject: [PATCH 11/56] Format code --- lib/src/ssh_transport.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index 3589a1d..0e6e6ee 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -201,7 +201,8 @@ class SSHTransport { // We need to account for the 1 byte padding length field final paddingLength = blockSize - ((data.length + 1) % blockSize); // Ensure padding is at least 4 bytes as per SSH spec - final adjustedPaddingLength = paddingLength < 4 ? paddingLength + blockSize : paddingLength; + final adjustedPaddingLength = + paddingLength < 4 ? paddingLength + blockSize : paddingLength; // Calculate the total packet length (excluding the length field itself) final packetLength = 1 + data.length + adjustedPaddingLength; @@ -217,12 +218,14 @@ class SSHTransport { // Add random padding for (var i = 0; i < adjustedPaddingLength; i++) { - payloadToEncrypt[1 + data.length + i] = (DateTime.now().microsecondsSinceEpoch + i) & 0xFF; + payloadToEncrypt[1 + data.length + i] = + (DateTime.now().microsecondsSinceEpoch + i) & 0xFF; } // Verify that the payload length is a multiple of the block size if (payloadToEncrypt.length % blockSize != 0) { - throw StateError('Payload length ${payloadToEncrypt.length} is not a multiple of block size $blockSize'); + throw StateError( + 'Payload length ${payloadToEncrypt.length} is not a multiple of block size $blockSize'); } // Encrypt the payload @@ -512,7 +515,8 @@ class SSHTransport { final packetLength = SSHPacket.readPacketLength(_decryptBuffer.data); _verifyPacketLength(packetLength); - if (_buffer.length + _decryptBuffer.length < 4 + packetLength + macLength) { + if (_buffer.length + _decryptBuffer.length < + 4 + packetLength + macLength) { return null; } @@ -560,10 +564,11 @@ class SSHTransport { /// Verifies that the MAC of the packet is correct. Throws [SSHPacketError] /// if the MAC is incorrect. - /// + /// /// For ETM (Encrypt-Then-MAC) algorithms, the MAC is calculated on the packet length and encrypted payload. /// For standard MAC algorithms, the MAC is calculated on the unencrypted packet. - void _verifyPacketMac(Uint8List payload, Uint8List actualMac, {bool isEncrypted = false}) { + void _verifyPacketMac(Uint8List payload, Uint8List actualMac, + {bool isEncrypted = false}) { final macSize = _remoteMac!.macSize; if (actualMac.length != macSize) { throw ArgumentError.value(actualMac, 'mac', 'Invalid MAC size'); From 25429ab84fb47d6a4722d9f646b77cbe4dd04a57 Mon Sep 17 00:00:00 2001 From: xuty Date: Sun, 22 Jun 2025 14:45:54 +0800 Subject: [PATCH 12/56] Format code --- lib/src/utils/truncated_hmac.dart | 2 +- test/src/algorithm/ssh_mac_type_test.dart | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/src/utils/truncated_hmac.dart b/lib/src/utils/truncated_hmac.dart index 0565255..00cb1e1 100644 --- a/lib/src/utils/truncated_hmac.dart +++ b/lib/src/utils/truncated_hmac.dart @@ -23,4 +23,4 @@ class TruncatedHMac extends HMac { return _truncatedSize; } -} \ No newline at end of file +} diff --git a/test/src/algorithm/ssh_mac_type_test.dart b/test/src/algorithm/ssh_mac_type_test.dart index a9c69ca..14d4e56 100644 --- a/test/src/algorithm/ssh_mac_type_test.dart +++ b/test/src/algorithm/ssh_mac_type_test.dart @@ -11,8 +11,10 @@ void main() { expect(SSHMacType.hmacSha1.name, equals('hmac-sha1')); expect(SSHMacType.hmacSha256.name, equals('hmac-sha2-256')); expect(SSHMacType.hmacSha512.name, equals('hmac-sha2-512')); - expect(SSHMacType.hmacSha256Etm.name, equals('hmac-sha2-256-etm@openssh.com')); - expect(SSHMacType.hmacSha512Etm.name, equals('hmac-sha2-512-etm@openssh.com')); + expect(SSHMacType.hmacSha256Etm.name, + equals('hmac-sha2-256-etm@openssh.com')); + expect(SSHMacType.hmacSha512Etm.name, + equals('hmac-sha2-512-etm@openssh.com')); expect(SSHMacType.hmacMd5.keySize, equals(16)); expect(SSHMacType.hmacSha1.keySize, equals(20)); From dc8607b37dae9e4196d2fa34c042d9cea8ac45e8 Mon Sep 17 00:00:00 2001 From: xuty Date: Sun, 22 Jun 2025 14:48:39 +0800 Subject: [PATCH 13/56] Update example --- example/sftp_upload.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/example/sftp_upload.dart b/example/sftp_upload.dart index 68f3c18..106d78c 100644 --- a/example/sftp_upload.dart +++ b/example/sftp_upload.dart @@ -22,6 +22,8 @@ void main(List args) async { await file.write(File('local_file.txt').openRead().cast()).done; print('done'); + await file.close(); + client.close(); await client.done; } From c7d632185fdb226c726e6033f92649d97b0fdf47 Mon Sep 17 00:00:00 2001 From: xuty Date: Sun, 22 Jun 2025 14:48:44 +0800 Subject: [PATCH 14/56] Add .vscode to .gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 85fca2c..336b955 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,6 @@ build/ doc/api/ .idea -.history \ No newline at end of file +.history + +.vscode \ No newline at end of file From df127d7b937d9a2b040c6bae3f1fc5bc0bba1722 Mon Sep 17 00:00:00 2001 From: xuty Date: Sun, 22 Jun 2025 14:54:10 +0800 Subject: [PATCH 15/56] Bump version --- CHANGELOG.md | 15 +++++++++++++-- pubspec.yaml | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a3f635..7bd0f49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ -## [2.13.0] - 2025-mm-dd +## [2.13.0] - 2025-06-22 - docs: Update NoPorts naming [#115]. [@XavierChanth]. +- Add parameter disableHostkeyVerification [#123]. Thanks [@alexander-irion]. +- Add support for server initiated re-keying [#125]. Thanks [@MarBazuz]. +- Add support for new algorithms "mac-sha2-256-96", "hmac-sha2-512-96", "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com" [#126] [#127]. Thanks [@reinbeumer]. ## [2.12.0] - 2025-02-08 - Fixed streams and channel not closing after receiving SSH_Message_Channel_Close [#116]. [@cbenhagen]. @@ -168,6 +171,10 @@ - Initial release. +[#127]: https://github.com/TerminalStudio/dartssh2/pull/127 +[#126]: https://github.com/TerminalStudio/dartssh2/pull/126 +[#125]: https://github.com/TerminalStudio/dartssh2/pull/125 +[#123]: https://github.com/TerminalStudio/dartssh2/pull/123 [#101]: https://github.com/TerminalStudio/dartssh2/pull/101 [#100]: https://github.com/TerminalStudio/dartssh2/issues/100 [#80]: https://github.com/TerminalStudio/dartssh2/issues/80 @@ -182,4 +189,8 @@ [@linhanyu]: https://github.com/linhanyu [@Migarl]: https://github.com/Migarl -[@PIDAMI]: https://github.com/PIDAMI \ No newline at end of file +[@PIDAMI]: https://github.com/PIDAMI +[@XavierChanth]: https://github.com/XavierChanth +[@MarBazuz]: https://github.com/MarBazuz +[@reinbeumer]: https://github.com/reinbeumer +[@alexander-irion]: https://github.com/alexander-irion \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 3f784b9..9d75950 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.12.0 +version: 2.13.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 1d9613c9939f726bbc99c3b0b23580d8f12c7ff5 Mon Sep 17 00:00:00 2001 From: "Victor C." Date: Sun, 20 Jul 2025 08:32:08 +0200 Subject: [PATCH 16/56] Removed Dependabot on package --- .github/dependabot.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1059862..ad8b29e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,12 +5,6 @@ version: 2 updates: - - package-ecosystem: "pub" - directory: "/" - schedule: - interval: "weekly" - time: "09:00" - timezone: Europe/Madrid - package-ecosystem: "pub" directory: "/example/" schedule: From 5cba8d7cacec48a971c7663eaf801e427691e633 Mon Sep 17 00:00:00 2001 From: "Victor C." Date: Sun, 20 Jul 2025 08:43:50 +0200 Subject: [PATCH 17/56] Added publish flow --- .github/workflows/publish_pub_dev.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/publish_pub_dev.yml diff --git a/.github/workflows/publish_pub_dev.yml b/.github/workflows/publish_pub_dev.yml new file mode 100644 index 0000000..8105a6d --- /dev/null +++ b/.github/workflows/publish_pub_dev.yml @@ -0,0 +1,14 @@ +name: Publish to pub.dev + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+*' + +jobs: + publish: + permissions: + id-token: write # Required for authentication using OIDC + uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 + # with: + # working-directory: path/to/package/within/repository \ No newline at end of file From 13127669c68ae65cc488b08eccd4963739da93d6 Mon Sep 17 00:00:00 2001 From: "Victor C." Date: Sun, 20 Jul 2025 08:50:21 +0200 Subject: [PATCH 18/56] Renamed file --- .github/workflows/{publish_pub_dev.yml => publish.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{publish_pub_dev.yml => publish.yml} (100%) diff --git a/.github/workflows/publish_pub_dev.yml b/.github/workflows/publish.yml similarity index 100% rename from .github/workflows/publish_pub_dev.yml rename to .github/workflows/publish.yml From 61600d5a17066724a5722cc2a2b47b22659c0939 Mon Sep 17 00:00:00 2001 From: James Thorpe Date: Sun, 2 Nov 2025 11:59:58 +1100 Subject: [PATCH 19/56] Support SSH-1.99 version Early SSH 2.0 implementations often used version 1.99 to indicated SSH 2.0 with fallback to SSH 1.0. These should be treated as SSH 2.0 for the purposes of this library. --- lib/src/ssh_transport.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index 46ba5e4..2e6f61c 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -373,7 +373,7 @@ class SSHTransport { } final versionString = bufferString.substring(0, index); - if (!versionString.startsWith('SSH-2.0-')) { + if (!(versionString.startsWith('SSH-2.0-') || versionString.startsWith('SSH-1.99-'))) { socket.sink.add(latin1.encode('Protocol mismatch\r\n')); throw SSHHandshakeError('Invalid version: $versionString'); } From 0d2d2884552cfad35dc808e377f47178edfb74c7 Mon Sep 17 00:00:00 2001 From: James Thorpe Date: Sun, 2 Nov 2025 12:10:56 +1100 Subject: [PATCH 20/56] Propagate underlying exception in SSHAuthAbortError Added SSHAuthAbortError.reason field to propagate the underlying exception so we actually know what happened. This enables users to handle different error scenarios correctly. --- lib/src/ssh_client.dart | 9 +++++---- lib/src/ssh_errors.dart | 4 +++- test/src/ssh_client_test.dart | 3 +++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index ee841f5..fb33181 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -170,8 +170,8 @@ class SSHClient { ); _transport.done.then( - (_) => _handleTransportClosed(), - onError: (_) => _handleTransportClosed(), + (_) => _handleTransportClosed(null), + onError: (e) => _handleTransportClosed(e), ); _authenticated.future.catchError( @@ -473,11 +473,12 @@ class SSHClient { _requestAuthentication(); } - void _handleTransportClosed() { + + void _handleTransportClosed(SSHError? error) { printDebug?.call('SSHClient._onTransportClosed'); if (!_authenticated.isCompleted) { _authenticated.completeError( - SSHAuthAbortError('Connection closed before authentication'), + SSHAuthAbortError('Connection closed before authentication', error) ); } _keepAlive?.stop(); diff --git a/lib/src/ssh_errors.dart b/lib/src/ssh_errors.dart index 68407de..11c6c94 100644 --- a/lib/src/ssh_errors.dart +++ b/lib/src/ssh_errors.dart @@ -50,7 +50,9 @@ class SSHAuthAbortError with SSHMessageError implements SSHAuthError { @override final String message; - SSHAuthAbortError(this.message); + final SSHError? reason; + + SSHAuthAbortError(this.message, this.reason); } /// Errors that happen when the library receives an malformed packet. diff --git a/test/src/ssh_client_test.dart b/test/src/ssh_client_test.dart index ecd9189..38171da 100644 --- a/test/src/ssh_client_test.dart +++ b/test/src/ssh_client_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; @@ -137,6 +139,7 @@ void main() { fail('should have thrown'); } catch (e) { expect(e, isA()); + expect((e as SSHAuthAbortError).reason!, isA()); } client.close(); From 1c5a937fd41a6c36c984b89bca83a49a1ab410bb Mon Sep 17 00:00:00 2001 From: Remulic <2501244285@qq.com> Date: Mon, 17 Nov 2025 11:35:28 +0800 Subject: [PATCH 21/56] feat: Expose SSH ident from ssh_client --- lib/src/ssh_client.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index ee841f5..c33160d 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -133,6 +133,10 @@ class SSHClient { /// Allow to disable hostkey verification, which can be slow in debug mode. final bool disableHostkeyVerification; + /// Identification string advertised during the SSH version exchange (the part + /// after `SSH-2.0-`). Defaults to `'DartSSH_2.0'` + final String ident; + /// A [Future] that completes when the transport is closed, or when an error /// occurs. After this [Future] completes, [isClosed] will be true and no more /// data can be sent or received. @@ -156,6 +160,7 @@ class SSHClient { this.onAuthenticated, this.keepAliveInterval = const Duration(seconds: 10), this.disableHostkeyVerification = false, + this.ident = 'DartSSH_2.0', }) { _transport = SSHTransport( socket, @@ -167,6 +172,7 @@ class SSHClient { onReady: _handleTransportReady, onPacket: _handlePacket, disableHostkeyVerification: disableHostkeyVerification, + version: ident, ); _transport.done.then( From 3aa45a511afc373e091822102d3ce2495c67f1a9 Mon Sep 17 00:00:00 2001 From: Trivix Date: Thu, 1 Jan 2026 23:06:12 +0800 Subject: [PATCH 22/56] Add NaviTerm screenshot to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index ce8e698..65c6605 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as e dartShell displaying terminal and session information for SSH operations + + + Screenshot of NaviTerm application built with dartssh2 + From f9d3a1d57c17264f5a24f05e20498fcaca25289a Mon Sep 17 00:00:00 2001 From: Trivix Date: Thu, 1 Jan 2026 23:24:21 +0800 Subject: [PATCH 23/56] add NaviTerm title and repository link Updated the showcase table to include the NaviTerm title and linked the name and image to the GitHub repository. --- README.md | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 65c6605..466ff7e 100644 --- a/README.md +++ b/README.md @@ -35,39 +35,37 @@ SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as e - - - + - - - -
ServerBox NoPorts DartShell + Naviterm +
- ServerBox interface displaying connection management options - ServerBox user interface for server control and monitoring + ServerBox interface + ServerBox interface - NoPorts demo showcasing SSH connectivity without open ports + NoPorts demo - dartShell displaying terminal and session information for SSH operations + dartShell interface - Screenshot of NaviTerm application built with dartssh2 + + NaviTerm: Professional SSH Terminal for iOS +
From 2bf1ef21968538caeb9233a9380e667064cf2c5b Mon Sep 17 00:00:00 2001 From: Trivix Date: Thu, 1 Jan 2026 23:29:41 +0800 Subject: [PATCH 24/56] Update App Store link for NaviTerm --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 466ff7e..22af41b 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as e dartShell interface - + NaviTerm: Professional SSH Terminal for iOS From 416d74efb7bbaa4fc736ef528944a85b2802128a Mon Sep 17 00:00:00 2001 From: Trivix Date: Thu, 1 Jan 2026 23:54:23 +0800 Subject: [PATCH 25/56] add NaviTerm to showcase and refine table structure Updated alt text for images to improve accessibility and clarity. --- README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 22af41b..83e2809 100644 --- a/README.md +++ b/README.md @@ -35,33 +35,41 @@ SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as e + + + + + + + + - + From 8db2d022bfbf3e60431cf37aed7b31ea2dd13e03 Mon Sep 17 00:00:00 2001 From: Hennie Brink Date: Tue, 27 Jan 2026 12:54:12 +0200 Subject: [PATCH 27/56] Add SSH agent forwarding support --- lib/dartssh2.dart | 1 + lib/src/message/msg_channel.dart | 1 + lib/src/ssh_agent.dart | 168 +++++++++++++++++++++++++++++++ lib/src/ssh_channel.dart | 11 ++ lib/src/ssh_client.dart | 51 ++++++++++ 5 files changed, 232 insertions(+) create mode 100644 lib/src/ssh_agent.dart diff --git a/lib/dartssh2.dart b/lib/dartssh2.dart index 956142a..031459f 100644 --- a/lib/dartssh2.dart +++ b/lib/dartssh2.dart @@ -1,4 +1,5 @@ export 'src/ssh_algorithm.dart' show SSHAlgorithms; +export 'src/ssh_agent.dart'; export 'src/ssh_client.dart'; export 'src/ssh_errors.dart'; export 'src/ssh_forward.dart'; diff --git a/lib/src/message/msg_channel.dart b/lib/src/message/msg_channel.dart index cab4111..14733cb 100644 --- a/lib/src/message/msg_channel.dart +++ b/lib/src/message/msg_channel.dart @@ -541,6 +541,7 @@ abstract class SSHChannelRequestType { static const shell = 'shell'; static const exec = 'exec'; static const subsystem = 'subsystem'; + static const authAgent = 'auth-agent-req@openssh.com'; static const windowChange = 'window-change'; static const xon = 'xon-xoff'; static const signal = 'signal'; diff --git a/lib/src/ssh_agent.dart b/lib/src/ssh_agent.dart new file mode 100644 index 0000000..c3651e6 --- /dev/null +++ b/lib/src/ssh_agent.dart @@ -0,0 +1,168 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/src/ssh_channel.dart'; +import 'package:dartssh2/src/ssh_key_pair.dart'; +import 'package:dartssh2/src/ssh_message.dart'; +import 'package:dartssh2/src/ssh_transport.dart'; + +abstract class SSHAgentHandler { + Future handleRequest(Uint8List request); +} + +class SSHKeyPairAgent implements SSHAgentHandler { + SSHKeyPairAgent(this._identities, {this.comment}); + + final List _identities; + final String? comment; + + @override + Future handleRequest(Uint8List request) async { + if (request.isEmpty) { + return _failure(); + } + final reader = SSHMessageReader(request); + final messageType = reader.readUint8(); + switch (messageType) { + case SSHAgentProtocol.requestIdentities: + return _handleRequestIdentities(); + case SSHAgentProtocol.signRequest: + return _handleSignRequest(reader); + default: + return _failure(); + } + } + + Uint8List _handleRequestIdentities() { + final writer = SSHMessageWriter(); + writer.writeUint8(SSHAgentProtocol.identitiesAnswer); + writer.writeUint32(_identities.length); + for (final identity in _identities) { + final publicKey = identity.toPublicKey().encode(); + writer.writeString(publicKey); + writer.writeUtf8(comment ?? ''); + } + return writer.takeBytes(); + } + + Uint8List _handleSignRequest(SSHMessageReader reader) { + final keyBlob = reader.readString(); + final data = reader.readString(); + reader.readUint32(); // flags, ignored for now + + final identity = _findIdentity(keyBlob); + if (identity == null) { + return _failure(); + } + + final signature = identity.sign(data); + final writer = SSHMessageWriter(); + writer.writeUint8(SSHAgentProtocol.signResponse); + writer.writeString(signature.encode()); + return writer.takeBytes(); + } + + SSHKeyPair? _findIdentity(Uint8List keyBlob) { + for (final identity in _identities) { + final publicKey = identity.toPublicKey().encode(); + if (_bytesEqual(publicKey, keyBlob)) { + return identity; + } + } + return null; + } + + Uint8List _failure() { + final writer = SSHMessageWriter(); + writer.writeUint8(SSHAgentProtocol.failure); + return writer.takeBytes(); + } + + bool _bytesEqual(Uint8List a, Uint8List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + +class SSHAgentChannel { + SSHAgentChannel(this._channel, this._handler, {this.printDebug}) { + _subscription = _channel.stream.listen( + _handleData, + onDone: _handleDone, + onError: (_, __) => _handleDone(), + ); + } + + final SSHChannel _channel; + final SSHAgentHandler _handler; + final SSHPrintHandler? printDebug; + + StreamSubscription? _subscription; + Uint8List _buffer = Uint8List(0); + bool _processing = false; + + void _handleDone() { + _subscription?.cancel(); + } + + void _handleData(SSHChannelData data) { + _buffer = _appendBytes(_buffer, data.bytes); + _drainRequests(); + } + + void _drainRequests() { + if (_processing) return; + _processing = true; + _processQueue().whenComplete(() => _processing = false); + } + + Future _processQueue() async { + while (_buffer.length >= 4) { + final length = ByteData.sublistView(_buffer, 0, 4).getUint32(0); + if (_buffer.length < 4 + length) return; + final payload = _buffer.sublist(4, 4 + length); + _buffer = _buffer.sublist(4 + length); + Uint8List response; + try { + response = await _handler.handleRequest(payload); + } catch (error) { + printDebug?.call('SSH agent handler error: $error'); + response = _failureResponse(); + } + _sendResponse(response); + } + } + + Uint8List _failureResponse() { + final writer = SSHMessageWriter(); + writer.writeUint8(SSHAgentProtocol.failure); + return writer.takeBytes(); + } + + void _sendResponse(Uint8List payload) { + final writer = SSHMessageWriter(); + writer.writeUint32(payload.length); + writer.writeBytes(payload); + _channel.addData(writer.takeBytes()); + } + + Uint8List _appendBytes(Uint8List a, Uint8List b) { + if (a.isEmpty) return b; + if (b.isEmpty) return a; + final combined = Uint8List(a.length + b.length); + combined.setAll(0, a); + combined.setAll(a.length, b); + return combined; + } +} + +abstract class SSHAgentProtocol { + static const int failure = 5; + static const int requestIdentities = 11; + static const int identitiesAnswer = 12; + static const int signRequest = 13; + static const int signResponse = 14; +} diff --git a/lib/src/ssh_channel.dart b/lib/src/ssh_channel.dart index 20f0fa8..4dec940 100644 --- a/lib/src/ssh_channel.dart +++ b/lib/src/ssh_channel.dart @@ -120,6 +120,17 @@ class SSHChannelController { return await _requestReplyQueue.next; } + Future sendAgentForwardingRequest() async { + sendMessage( + SSH_Message_Channel_Request( + recipientChannel: remoteId, + requestType: SSHChannelRequestType.authAgent, + wantReply: true, + ), + ); + return await _requestReplyQueue.next; + } + Future sendSubsystem(String subsystem) async { sendMessage( SSH_Message_Channel_Request.subsystem( diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index ee841f5..7c4cfd8 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'package:dartssh2/src/http/http_client.dart'; import 'package:dartssh2/src/sftp/sftp_client.dart'; import 'package:dartssh2/src/ssh_algorithm.dart'; +import 'package:dartssh2/src/ssh_agent.dart'; import 'package:dartssh2/src/ssh_channel.dart'; import 'package:dartssh2/src/ssh_channel_id.dart'; import 'package:dartssh2/src/ssh_errors.dart'; @@ -122,6 +123,9 @@ class SSHClient { /// Function called when authentication is complete. final SSHAuthenticatedHandler? onAuthenticated; + /// Optional handler for SSH agent forwarding requests. + final SSHAgentHandler? agentHandler; + /// The interval at which to send a keep-alive message through the [ping] /// method. Set this to null to disable automatic keep-alive messages. final Duration? keepAliveInterval; @@ -154,6 +158,7 @@ class SSHClient { this.onUserInfoRequest, this.onUserauthBanner, this.onAuthenticated, + this.agentHandler, this.keepAliveInterval = const Duration(seconds: 10), this.disableHostkeyVerification = false, }) { @@ -314,6 +319,10 @@ class SSHClient { } } + if (agentHandler != null) { + await channelController.sendAgentForwardingRequest(); + } + if (pty != null) { final ptyOk = await channelController.sendPtyReq( terminalType: pty.type, @@ -353,6 +362,10 @@ class SSHClient { } } + if (agentHandler != null) { + await channelController.sendAgentForwardingRequest(); + } + if (pty != null) { final ok = await channelController.sendPtyReq( terminalType: pty.type, @@ -676,6 +689,8 @@ class SSHClient { switch (message.channelType) { case 'forwarded-tcpip': return _handleForwardedTcpipChannelOpen(message); + case 'auth-agent@openssh.com': + return _handleAgentChannelOpen(message); } printDebug?.call('unknown channelType: ${message.channelType}'); @@ -740,6 +755,42 @@ class SSHClient { ); } + void _handleAgentChannelOpen(SSH_Message_Channel_Open message) { + final handler = agentHandler; + if (handler == null) { + final reply = SSH_Message_Channel_Open_Failure( + recipientChannel: message.senderChannel, + reasonCode: SSH_Message_Channel_Open_Failure.codeUnknownChannelType, + description: 'agent forwarding not enabled', + ); + _sendMessage(reply); + return; + } + + final localChannelId = _channelIdAllocator.allocate(); + final confirmation = SSH_Message_Channel_Confirmation( + recipientChannel: message.senderChannel, + senderChannel: localChannelId, + initialWindowSize: _initialWindowSize, + maximumPacketSize: _maximumPacketSize, + data: Uint8List(0), + ); + _sendMessage(confirmation); + + final channelController = _acceptChannel( + localChannelId: localChannelId, + remoteChannelId: message.senderChannel, + remoteInitialWindowSize: message.initialWindowSize, + remoteMaximumPacketSize: message.maximumPacketSize, + ); + + SSHAgentChannel( + channelController.channel, + handler, + printDebug: printDebug, + ); + } + /// Finds a remote forward that matches the given host and port. SSHRemoteForward? _findRemoteForward(String host, int port) { final result = _remoteForwards.where( From c51d71d8f0efa0fe25f13a0dd3577243e2b247ff Mon Sep 17 00:00:00 2001 From: Hennie Brink Date: Tue, 27 Jan 2026 13:00:08 +0200 Subject: [PATCH 28/56] Honor RSA sign-request flags in agent --- lib/src/ssh_agent.dart | 68 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/lib/src/ssh_agent.dart b/lib/src/ssh_agent.dart index c3651e6..1b5e83e 100644 --- a/lib/src/ssh_agent.dart +++ b/lib/src/ssh_agent.dart @@ -1,10 +1,16 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:dartssh2/src/hostkey/hostkey_rsa.dart'; import 'package:dartssh2/src/ssh_channel.dart'; import 'package:dartssh2/src/ssh_key_pair.dart'; import 'package:dartssh2/src/ssh_message.dart'; import 'package:dartssh2/src/ssh_transport.dart'; +import 'package:pointycastle/asymmetric/api.dart' as asymmetric; +import 'package:pointycastle/digests/sha1.dart'; +import 'package:pointycastle/digests/sha256.dart'; +import 'package:pointycastle/digests/sha512.dart'; +import 'package:pointycastle/signers/rsa_signer.dart'; abstract class SSHAgentHandler { Future handleRequest(Uint8List request); @@ -48,20 +54,76 @@ class SSHKeyPairAgent implements SSHAgentHandler { Uint8List _handleSignRequest(SSHMessageReader reader) { final keyBlob = reader.readString(); final data = reader.readString(); - reader.readUint32(); // flags, ignored for now + final flags = reader.readUint32(); final identity = _findIdentity(keyBlob); if (identity == null) { return _failure(); } - final signature = identity.sign(data); + final signature = _sign(identity, data, flags); final writer = SSHMessageWriter(); writer.writeUint8(SSHAgentProtocol.signResponse); writer.writeString(signature.encode()); return writer.takeBytes(); } + SSHSignature _sign(SSHKeyPair identity, Uint8List data, int flags) { + if (identity is OpenSSHRsaKeyPair || identity is RsaPrivateKey) { + final signatureType = _rsaSignatureTypeForFlags(flags); + return _signRsa(identity, data, signatureType); + } + return identity.sign(data); + } + + String _rsaSignatureTypeForFlags(int flags) { + if (flags & SSHAgentProtocol.rsaSha2_512 != 0) { + return SSHRsaSignatureType.sha512; + } + if (flags & SSHAgentProtocol.rsaSha2_256 != 0) { + return SSHRsaSignatureType.sha256; + } + return SSHRsaSignatureType.sha1; + } + + SSHRsaSignature _signRsa( + SSHKeyPair identity, + Uint8List data, + String signatureType, + ) { + final key = _rsaKeyFrom(identity); + if (key == null) { + return identity.sign(data) as SSHRsaSignature; + } + + final signer = _rsaSignerFor(signatureType); + signer.init(true, asymmetric.PrivateKeyParameter(key)); + return SSHRsaSignature(signatureType, signer.generateSignature(data).bytes); + } + + asymmetric.RSAPrivateKey? _rsaKeyFrom(SSHKeyPair identity) { + if (identity is OpenSSHRsaKeyPair) { + return asymmetric.RSAPrivateKey(identity.n, identity.d, identity.p, identity.q); + } + if (identity is RsaPrivateKey) { + return asymmetric.RSAPrivateKey(identity.n, identity.d, identity.p, identity.q); + } + return null; + } + + RSASigner _rsaSignerFor(String signatureType) { + switch (signatureType) { + case SSHRsaSignatureType.sha1: + return RSASigner(SHA1Digest(), '06052b0e03021a'); + case SSHRsaSignatureType.sha256: + return RSASigner(SHA256Digest(), '0609608648016503040201'); + case SSHRsaSignatureType.sha512: + return RSASigner(SHA512Digest(), '0609608648016503040203'); + default: + return RSASigner(SHA256Digest(), '0609608648016503040201'); + } + } + SSHKeyPair? _findIdentity(Uint8List keyBlob) { for (final identity in _identities) { final publicKey = identity.toPublicKey().encode(); @@ -165,4 +227,6 @@ abstract class SSHAgentProtocol { static const int identitiesAnswer = 12; static const int signRequest = 13; static const int signResponse = 14; + static const int rsaSha2_256 = 2; + static const int rsaSha2_512 = 4; } From 96a1d553c6b90be52ad63908b7eb02b5a870d95e Mon Sep 17 00:00:00 2001 From: Hennie Brink Date: Tue, 27 Jan 2026 13:28:46 +0200 Subject: [PATCH 29/56] Fix agent RSA signing imports and add tests --- lib/src/ssh_agent.dart | 4 +- test/src/ssh_agent_test.dart | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 test/src/ssh_agent_test.dart diff --git a/lib/src/ssh_agent.dart b/lib/src/ssh_agent.dart index 1b5e83e..b89317f 100644 --- a/lib/src/ssh_agent.dart +++ b/lib/src/ssh_agent.dart @@ -3,9 +3,11 @@ import 'dart:typed_data'; import 'package:dartssh2/src/hostkey/hostkey_rsa.dart'; import 'package:dartssh2/src/ssh_channel.dart'; +import 'package:dartssh2/src/ssh_hostkey.dart'; import 'package:dartssh2/src/ssh_key_pair.dart'; import 'package:dartssh2/src/ssh_message.dart'; import 'package:dartssh2/src/ssh_transport.dart'; +import 'package:pointycastle/api.dart' hide Signature; import 'package:pointycastle/asymmetric/api.dart' as asymmetric; import 'package:pointycastle/digests/sha1.dart'; import 'package:pointycastle/digests/sha256.dart'; @@ -97,7 +99,7 @@ class SSHKeyPairAgent implements SSHAgentHandler { } final signer = _rsaSignerFor(signatureType); - signer.init(true, asymmetric.PrivateKeyParameter(key)); + signer.init(true, PrivateKeyParameter(key)); return SSHRsaSignature(signatureType, signer.generateSignature(data).bytes); } diff --git a/test/src/ssh_agent_test.dart b/test/src/ssh_agent_test.dart new file mode 100644 index 0000000..2f36987 --- /dev/null +++ b/test/src/ssh_agent_test.dart @@ -0,0 +1,71 @@ +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/src/hostkey/hostkey_rsa.dart'; +import 'package:dartssh2/src/ssh_message.dart'; +import 'package:test/test.dart'; + +import '../test_utils.dart'; + +void main() { + final rsaPrivate = fixture('ssh-rsa/id_rsa'); + + SSHKeyPair rsaIdentity() { + return SSHKeyPair.fromPem(rsaPrivate).single; + } + + Uint8List buildRequestIdentities() { + final writer = SSHMessageWriter(); + writer.writeUint8(SSHAgentProtocol.requestIdentities); + return writer.takeBytes(); + } + + Uint8List buildSignRequest(SSHKeyPair identity, Uint8List data, int flags) { + final writer = SSHMessageWriter(); + writer.writeUint8(SSHAgentProtocol.signRequest); + writer.writeString(identity.toPublicKey().encode()); + writer.writeString(data); + writer.writeUint32(flags); + return writer.takeBytes(); + } + + test('SSHKeyPairAgent returns identities', () async { + final identity = rsaIdentity(); + final agent = SSHKeyPairAgent([identity], comment: 'test-key'); + + final response = await agent.handleRequest(buildRequestIdentities()); + final reader = SSHMessageReader(response); + + expect(reader.readUint8(), SSHAgentProtocol.identitiesAnswer); + expect(reader.readUint32(), 1); + final keyBlob = reader.readString(); + final comment = reader.readUtf8(); + + expect(keyBlob, identity.toPublicKey().encode()); + expect(comment, 'test-key'); + }); + + test('SSHKeyPairAgent signs RSA with expected signature type', () async { + final identity = rsaIdentity(); + final agent = SSHKeyPairAgent([identity]); + final data = Uint8List.fromList('sign-me'.codeUnits); + + final cases = { + SSHAgentProtocol.rsaSha2_256: SSHRsaSignatureType.sha256, + SSHAgentProtocol.rsaSha2_512: SSHRsaSignatureType.sha512, + 0: SSHRsaSignatureType.sha1, + }; + + for (final entry in cases.entries) { + final response = await agent.handleRequest( + buildSignRequest(identity, data, entry.key), + ); + final reader = SSHMessageReader(response); + expect(reader.readUint8(), SSHAgentProtocol.signResponse); + + final signatureBlob = reader.readString(); + final signature = SSHRsaSignature.decode(signatureBlob); + expect(signature.type, entry.value); + } + }); +} From 4499ab6ddafdf785432e4d1001d15e6fe51e5571 Mon Sep 17 00:00:00 2001 From: Ivgeni Segal Date: Fri, 13 Feb 2026 07:19:21 -0800 Subject: [PATCH 30/56] Introducing domain sockets as forwarding connect destination --- lib/src/message/msg_channel.dart | 39 ++++++++++++++++++++++++++++++++ lib/src/ssh_client.dart | 30 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/lib/src/message/msg_channel.dart b/lib/src/message/msg_channel.dart index cab4111..5d49884 100644 --- a/lib/src/message/msg_channel.dart +++ b/lib/src/message/msg_channel.dart @@ -24,6 +24,9 @@ class SSH_Message_Channel_Open implements SSHMessage { final String? originatorIP; final int? originatorPort; + /// "direct-streamlocal@openssh.com" channel specific data. + final String? socketPath; + SSH_Message_Channel_Open({ required this.channelType, required this.senderChannel, @@ -33,6 +36,7 @@ class SSH_Message_Channel_Open implements SSHMessage { this.port, this.originatorIP, this.originatorPort, + this.socketPath, }); factory SSH_Message_Channel_Open.session({ @@ -107,6 +111,23 @@ class SSH_Message_Channel_Open implements SSHMessage { ); } + /// Opens a channel to forward data to a Unix domain socket on the remote + /// side. See OpenSSH PROTOCOL, section 2.4. + factory SSH_Message_Channel_Open.directStreamLocal({ + required int senderChannel, + required int initialWindowSize, + required int maximumPacketSize, + required String socketPath, + }) { + return SSH_Message_Channel_Open( + channelType: 'direct-streamlocal@openssh.com', + senderChannel: senderChannel, + initialWindowSize: initialWindowSize, + maximumPacketSize: maximumPacketSize, + socketPath: socketPath, + ); + } + factory SSH_Message_Channel_Open.decode(Uint8List bytes) { final reader = SSHMessageReader(bytes); reader.skip(1); @@ -159,6 +180,17 @@ class SSH_Message_Channel_Open implements SSHMessage { originatorIP: originatorIP, originatorPort: originatorPort, ); + case 'direct-streamlocal@openssh.com': + final socketPath = reader.readUtf8(); + // reserved string and uint32 per OpenSSH PROTOCOL spec + reader.readUtf8(); + reader.readUint32(); + return SSH_Message_Channel_Open.directStreamLocal( + senderChannel: senderChannel, + initialWindowSize: initialWindowSize, + maximumPacketSize: maximumPacketSize, + socketPath: socketPath, + ); default: return SSH_Message_Channel_Open( @@ -197,6 +229,11 @@ class SSH_Message_Channel_Open implements SSHMessage { writer.writeUtf8(originatorIP!); writer.writeUint32(originatorPort!); break; + case 'direct-streamlocal@openssh.com': + writer.writeUtf8(socketPath!); + writer.writeUtf8(''); // reserved + writer.writeUint32(0); // reserved + break; } return writer.takeBytes(); } @@ -212,6 +249,8 @@ class SSH_Message_Channel_Open implements SSHMessage { return 'SSH_Message_Channel_Open(channelType: $channelType, senderChannel: $senderChannel, initialWindowSize: $initialWindowSize, maximumPacketSize: $maximumPacketSize, host: $host, port: $port, originatorIP: $originatorIP, originatorPort: $originatorPort)'; case 'direct-tcpip': return 'SSH_Message_Channel_Open(channelType: $channelType, senderChannel: $senderChannel, initialWindowSize: $initialWindowSize, maximumPacketSize: $maximumPacketSize, host: $host, port: $port, originatorIP: $originatorIP, originatorPort: $originatorPort)'; + case 'direct-streamlocal@openssh.com': + return 'SSH_Message_Channel_Open(channelType: $channelType, senderChannel: $senderChannel, initialWindowSize: $initialWindowSize, maximumPacketSize: $maximumPacketSize, socketPath: $socketPath)'; default: return 'SSH_Message_Channel_Open(channelType: $channelType, senderChannel: $senderChannel, initialWindowSize: $initialWindowSize, maximumPacketSize: $maximumPacketSize)'; } diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index ee841f5..834a2c5 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -297,6 +297,20 @@ class SSHClient { return SSHForwardChannel(channelController.channel); } + /// Forward connections to a Unix domain socket at [remoteSocketPath] on the + /// remote side via a `direct-streamlocal@openssh.com` channel. + /// + /// This is the equivalent of `ssh -L localPort:remoteSocketPath`. + Future forwardLocalUnix( + String remoteSocketPath, + ) async { + await _authenticated.future; + final channelController = await _openForwardLocalUnixChannel( + remoteSocketPath, + ); + return SSHForwardChannel(channelController.channel); + } + /// Execute [command] on the remote side. Returns a [SSHChannel] that can be /// used to read and write to the remote side. Future execute( @@ -953,6 +967,22 @@ class SSHClient { return await _waitChannelOpen(localChannelId); } + Future _openForwardLocalUnixChannel( + String socketPath, + ) async { + final localChannelId = _channelIdAllocator.allocate(); + + final request = SSH_Message_Channel_Open.directStreamLocal( + senderChannel: localChannelId, + initialWindowSize: _initialWindowSize, + maximumPacketSize: _maximumPacketSize, + socketPath: socketPath, + ); + _sendMessage(request); + + return await _waitChannelOpen(localChannelId); + } + Future _waitChannelOpen( SSHChannelId localChannelId, ) async { From 81e88d7d4af89c170f06baf31fc471e1b64f1d9f Mon Sep 17 00:00:00 2001 From: Ivgeni Segal Date: Sat, 14 Feb 2026 13:02:14 -0800 Subject: [PATCH 31/56] Add example of forwarding to a remote unix domain socket --- example/forward_local_unix.dart | 33 +++++++++++++++++++++++++++++++++ lib/src/ssh_client.dart | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 example/forward_local_unix.dart diff --git a/example/forward_local_unix.dart b/example/forward_local_unix.dart new file mode 100644 index 0000000..853ef00 --- /dev/null +++ b/example/forward_local_unix.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:dartssh2/dartssh2.dart'; + +/// Example of forwarding a local TCP port to a remote Unix domain socket using `ssh -L localPort:remoteSocketPath`. +void main(List args) async { + final socket = await SSHSocket.connect('localhost', 22); + + final client = SSHClient( + socket, + username: 'root', + onPasswordRequest: () { + stdout.write('Password: '); + stdin.echoMode = false; + return stdin.readLineSync() ?? exit(1); + }, + ); + + await client.authenticated; + + final serverSocket = await ServerSocket.bind('localhost', 8080); + + print('Listening on ${serverSocket.address.address}:${serverSocket.port}'); + + await for (final socket in serverSocket) { + final forward = await client.forwardLocalUnix('/var/run/docker.sock'); + forward.stream.cast>().pipe(socket); + socket.cast>().pipe(forward.sink); + } + + client.close(); + await client.done; +} diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index 834a2c5..723d9b3 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -297,7 +297,7 @@ class SSHClient { return SSHForwardChannel(channelController.channel); } - /// Forward connections to a Unix domain socket at [remoteSocketPath] on the + /// Forward local connections to a remote Unix domain socket at [remoteSocketPath] on the /// remote side via a `direct-streamlocal@openssh.com` channel. /// /// This is the equivalent of `ssh -L localPort:remoteSocketPath`. From 963e9f9f7012ee185a272a703af4c83f5c001d7a Mon Sep 17 00:00:00 2001 From: Ivgeni Segal Date: Sat, 14 Mar 2026 08:07:13 -0700 Subject: [PATCH 32/56] Fix potential hang when closing channel while opening --- lib/src/ssh_client.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index 723d9b3..931d7ca 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -496,6 +496,17 @@ class SSHClient { } _keepAlive?.stop(); + // Complete any pending channel-open waiters so callers (e.g. + // forwardLocalUnix) don't hang forever when the connection drops. + for (final entry in _channelOpenReplyWaiters.entries) { + if (!entry.value.isCompleted) { + entry.value.completeError( + SSHStateError('Connection closed while waiting for channel open'), + ); + } + } + _channelOpenReplyWaiters.clear(); + try { _closeChannels(); } catch (e) { From e7a38eea466ca3e74e1ccebdef6e1f190358005b Mon Sep 17 00:00:00 2001 From: "yuanyuan.liu" Date: Wed, 18 Mar 2026 21:44:59 +0800 Subject: [PATCH 33/56] register channel --- lib/src/ssh_client.dart | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index ee841f5..4b67115 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -751,6 +751,17 @@ class SSHClient { void _handleChannelConfirmation(Uint8List payload) { final message = SSH_Message_Channel_Confirmation.decode(payload); printTrace?.call('<- $socket: $message'); + // Register the channel synchronously BEFORE completing the future. + // CHANNEL_DATA for this channel may arrive in the same TCP segment as + // the CONFIRMATION. If we defer registration to the async continuation + // of _waitChannelOpen, that data hits _handleChannelData while + // _channels[id] is still null and is silently dropped. + _acceptChannel( + localChannelId: message.recipientChannel, + remoteChannelId: message.senderChannel, + remoteInitialWindowSize: message.initialWindowSize, + remoteMaximumPacketSize: message.maximumPacketSize, + ); _dispatchChannelOpenReply(message.recipientChannel, message); } @@ -961,17 +972,8 @@ class SSHClient { throw SSHChannelOpenError(message.reasonCode, message.description); } - final reply = message as SSH_Message_Channel_Confirmation; - if (reply.recipientChannel != localChannelId) { - throw SSHStateError('Unexpected channel confirmation'); - } - - return _acceptChannel( - localChannelId: localChannelId, - remoteChannelId: reply.senderChannel, - remoteInitialWindowSize: reply.initialWindowSize, - remoteMaximumPacketSize: reply.maximumPacketSize, - ); + // Channel was already registered synchronously in _handleChannelConfirmation. + return _channels[localChannelId]!; } SSHChannelController _acceptChannel({ From 7b1a4390c9e9859285b90619ee2e1acb7903045a Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:56:12 +0100 Subject: [PATCH 34/56] Bump version to 2.14.0 and update CHANGELOG for SSH connection fix --- CHANGELOG.md | 3 +++ pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bd0f49..2a302d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [2.14.0] - 2026-03-19 +- Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks @shihuili1218. + ## [2.13.0] - 2025-06-22 - docs: Update NoPorts naming [#115]. [@XavierChanth]. - Add parameter disableHostkeyVerification [#123]. Thanks [@alexander-irion]. diff --git a/pubspec.yaml b/pubspec.yaml index 9d75950..ae33b99 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.13.0 +version: 2.14.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 78cf43a0061a347d6ea4742d3b21729d1213c6f5 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:01:53 +0100 Subject: [PATCH 35/56] Update GitHub Actions workflow to include pull request triggers and modify SSHSocket test connection --- .github/workflows/dart.yml | 8 +++++++- test/src/socket/ssh_socket_io_test.dart | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 6ee41a6..f3c339d 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -1,6 +1,12 @@ name: Dart -on: push +on: + push: + branches: + - master + pull_request: + branches: + - master jobs: test: diff --git a/test/src/socket/ssh_socket_io_test.dart b/test/src/socket/ssh_socket_io_test.dart index 82c4be9..626f678 100644 --- a/test/src/socket/ssh_socket_io_test.dart +++ b/test/src/socket/ssh_socket_io_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { group('SSHSocket', () { test('can establish tcp connections', () async { - final socket = await SSHSocket.connect('time.nist.gov', 13); + final socket = await SSHSocket.connect('test.rebex.net', 22); final firstPacket = await socket.stream.first; expect(firstPacket, isNotEmpty); await socket.close(); From 6ae02d4a08ffa3a4e9d5bfca1843644eda7b668f Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:11:38 +0100 Subject: [PATCH 36/56] Add forwardLocalUnix() function to CHANGELOG for SSH forwarding --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a302d9..3e4322d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## [2.14.0] - 2026-03-19 - Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks @shihuili1218. +- Adds a new forwardLocalUnix() function, which is an equivalent of ssh -L localPort:remoteSocketPath [#140]. Thanks @isegal. + ## [2.13.0] - 2025-06-22 - docs: Update NoPorts naming [#115]. [@XavierChanth]. From 98ac20b73370147ad421d5ad0b317742a1855260 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:25:52 +0100 Subject: [PATCH 37/56] chore(deps): update pointycastle to ^4.0.0 --- CHANGELOG.md | 2 ++ pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e4322d..8e0deca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## [2.14.0] - 2026-03-19 - Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks @shihuili1218. - Adds a new forwardLocalUnix() function, which is an equivalent of ssh -L localPort:remoteSocketPath [#140]. Thanks @isegal. +- Updated `pointycastle` dependency to `^4.0.0` [#131]. +- CI now runs tests for pull requests targeting `master`. ## [2.13.0] - 2025-06-22 diff --git a/pubspec.yaml b/pubspec.yaml index ae33b99..12f0d78 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ dependencies: asn1lib: ^1.5.8 convert: ^3.1.2 meta: ^1.16.0 - pointycastle: ^3.9.1 + pointycastle: ^4.0.0 pinenacl: ^0.6.0 dev_dependencies: From 743b45928f1f398338737e46319f93693142d672 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:28:05 +0100 Subject: [PATCH 38/56] chore: update version to 2.15.0 and modify CHANGELOG for pointycastle update --- CHANGELOG.md | 7 ++++--- pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e0deca..b14cac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ +## [2.15.0] - yyyy-mm-dd +- Updated `pointycastle` dependency to `^4.0.0` [#131]. Thanks @vicajilau. +- CI now runs tests for pull requests targeting `master`. + ## [2.14.0] - 2026-03-19 - Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks @shihuili1218. - Adds a new forwardLocalUnix() function, which is an equivalent of ssh -L localPort:remoteSocketPath [#140]. Thanks @isegal. -- Updated `pointycastle` dependency to `^4.0.0` [#131]. -- CI now runs tests for pull requests targeting `master`. - ## [2.13.0] - 2025-06-22 - docs: Update NoPorts naming [#115]. [@XavierChanth]. diff --git a/pubspec.yaml b/pubspec.yaml index 12f0d78..f4e0d27 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.14.0 +version: 2.15.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 7e7f14abf0417c45c08c8064cf361fdb7d6b5bbe Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:47:52 +0100 Subject: [PATCH 39/56] feat: add foundational X11 forwarding support --- CHANGELOG.md | 2 +- lib/src/ssh_channel.dart | 33 +++++++++ lib/src/ssh_client.dart | 98 ++++++++++++++++++++++++++ lib/src/ssh_forward.dart | 14 ++++ test/src/message/msg_channel_test.dart | 48 +++++++++++++ 5 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 test/src/message/msg_channel_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b14cac6..bf57a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [2.15.0] - yyyy-mm-dd - Updated `pointycastle` dependency to `^4.0.0` [#131]. Thanks @vicajilau. -- CI now runs tests for pull requests targeting `master`. +- Added foundational X11 forwarding support with session x11-req API, incoming x11 channel handling, and protocol tests [#1]. Thanks @vicajilau. ## [2.14.0] - 2026-03-19 - Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks @shihuili1218. diff --git a/lib/src/ssh_channel.dart b/lib/src/ssh_channel.dart index 20f0fa8..76fb49b 100644 --- a/lib/src/ssh_channel.dart +++ b/lib/src/ssh_channel.dart @@ -120,6 +120,25 @@ class SSHChannelController { return await _requestReplyQueue.next; } + Future sendX11Req({ + bool singleConnection = false, + String authenticationProtocol = 'MIT-MAGIC-COOKIE-1', + required String authenticationCookie, + int screenNumber = 0, + }) async { + sendMessage( + SSH_Message_Channel_Request.x11( + recipientChannel: remoteId, + wantReply: true, + singleConnection: singleConnection, + x11AuthenticationProtocol: authenticationProtocol, + x11AuthenticationCookie: authenticationCookie, + x11ScreenNumber: screenNumber.toString(), + ), + ); + return await _requestReplyQueue.next; + } + Future sendSubsystem(String subsystem) async { sendMessage( SSH_Message_Channel_Request.subsystem( @@ -415,6 +434,20 @@ class SSHChannel { return await _controller.sendShell(); } + Future sendX11Req({ + bool singleConnection = false, + String authenticationProtocol = 'MIT-MAGIC-COOKIE-1', + required String authenticationCookie, + int screenNumber = 0, + }) async { + return await _controller.sendX11Req( + singleConnection: singleConnection, + authenticationProtocol: authenticationProtocol, + authenticationCookie: authenticationCookie, + screenNumber: screenNumber, + ); + } + void sendTerminalWindowChange({ required int width, required int height, diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index fd7b92b..c34347f 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -41,6 +41,8 @@ typedef SSHAuthenticatedHandler = void Function(); typedef SSHRemoteConnectionFilter = bool Function(String host, int port); +typedef SSHX11ForwardHandler = void Function(SSHX11Channel channel); + // /// Function called when the host has sent additional host keys after the initial // /// key exchange. // typedef SSHHostKeysHandler = void Function(List); @@ -74,6 +76,27 @@ class SSHPtyConfig { }); } +class SSHX11Config { + /// Whether only a single forwarded X11 connection should be accepted. + final bool singleConnection; + + /// X11 authentication protocol name. + final String authenticationProtocol; + + /// X11 authentication cookie value. + final String authenticationCookie; + + /// X11 screen number. + final int screenNumber; + + const SSHX11Config({ + required this.authenticationCookie, + this.singleConnection = false, + this.authenticationProtocol = 'MIT-MAGIC-COOKIE-1', + this.screenNumber = 0, + }); +} + class SSHClient { final SSHSocket socket; @@ -122,6 +145,9 @@ class SSHClient { /// Function called when authentication is complete. final SSHAuthenticatedHandler? onAuthenticated; + /// Function called when the server opens an incoming forwarded X11 channel. + final SSHX11ForwardHandler? onX11Forward; + /// The interval at which to send a keep-alive message through the [ping] /// method. Set this to null to disable automatic keep-alive messages. final Duration? keepAliveInterval; @@ -154,6 +180,7 @@ class SSHClient { this.onUserInfoRequest, this.onUserauthBanner, this.onAuthenticated, + this.onX11Forward, this.keepAliveInterval = const Duration(seconds: 10), this.disableHostkeyVerification = false, }) { @@ -316,6 +343,7 @@ class SSHClient { Future execute( String command, { SSHPtyConfig? pty, + SSHX11Config? x11, Map? environment, }) async { await _authenticated.future; @@ -342,6 +370,19 @@ class SSHClient { } } + if (x11 != null) { + final x11Ok = await channelController.sendX11Req( + singleConnection: x11.singleConnection, + authenticationProtocol: x11.authenticationProtocol, + authenticationCookie: x11.authenticationCookie, + screenNumber: x11.screenNumber, + ); + if (!x11Ok) { + channelController.close(); + throw SSHChannelRequestError('Failed to request x11 forwarding'); + } + } + final success = await channelController.sendExec(command); if (!success) { channelController.close(); @@ -355,6 +396,7 @@ class SSHClient { /// used to read, write and control the pty on the remote side. Future shell({ SSHPtyConfig? pty = const SSHPtyConfig(), + SSHX11Config? x11, Map? environment, }) async { await _authenticated.future; @@ -381,6 +423,19 @@ class SSHClient { } } + if (x11 != null) { + final x11Ok = await channelController.sendX11Req( + singleConnection: x11.singleConnection, + authenticationProtocol: x11.authenticationProtocol, + authenticationCookie: x11.authenticationCookie, + screenNumber: x11.screenNumber, + ); + if (!x11Ok) { + channelController.close(); + throw SSHChannelRequestError('Failed to request x11 forwarding'); + } + } + if (!await channelController.sendShell()) { channelController.close(); throw SSHChannelRequestError('Failed to start shell'); @@ -701,6 +756,8 @@ class SSHClient { switch (message.channelType) { case 'forwarded-tcpip': return _handleForwardedTcpipChannelOpen(message); + case 'x11': + return _handleX11ChannelOpen(message); } printDebug?.call('unknown channelType: ${message.channelType}'); @@ -765,6 +822,47 @@ class SSHClient { ); } + void _handleX11ChannelOpen(SSH_Message_Channel_Open message) { + printDebug?.call('SSHClient._handleX11ChannelOpen'); + + if (onX11Forward == null) { + final reply = SSH_Message_Channel_Open_Failure( + recipientChannel: message.senderChannel, + reasonCode: 1, // SSH_OPEN_ADMINISTRATIVELY_PROHIBITED + description: 'x11 forwarding not enabled', + ); + _sendMessage(reply); + return; + } + + final localChannelId = _channelIdAllocator.allocate(); + + final confirmation = SSH_Message_Channel_Confirmation( + recipientChannel: message.senderChannel, + senderChannel: localChannelId, + initialWindowSize: _initialWindowSize, + maximumPacketSize: _maximumPacketSize, + data: Uint8List(0), + ); + + _sendMessage(confirmation); + + final channelController = _acceptChannel( + localChannelId: localChannelId, + remoteChannelId: message.senderChannel, + remoteInitialWindowSize: message.initialWindowSize, + remoteMaximumPacketSize: message.maximumPacketSize, + ); + + onX11Forward!( + SSHX11Channel( + channelController.channel, + originatorIP: message.originatorIP ?? '', + originatorPort: message.originatorPort ?? 0, + ), + ); + } + /// Finds a remote forward that matches the given host and port. SSHRemoteForward? _findRemoteForward(String host, int port) { final result = _remoteForwards.where( diff --git a/lib/src/ssh_forward.dart b/lib/src/ssh_forward.dart index 2494df3..f2b462b 100644 --- a/lib/src/ssh_forward.dart +++ b/lib/src/ssh_forward.dart @@ -39,3 +39,17 @@ class SSHForwardChannel implements SSHSocket { _channel.destroy(); } } + +class SSHX11Channel extends SSHForwardChannel { + /// Originator address reported by the SSH server for this X11 channel. + final String originatorIP; + + /// Originator port reported by the SSH server for this X11 channel. + final int originatorPort; + + SSHX11Channel( + super.channel, { + required this.originatorIP, + required this.originatorPort, + }); +} diff --git a/test/src/message/msg_channel_test.dart b/test/src/message/msg_channel_test.dart new file mode 100644 index 0000000..12b08f7 --- /dev/null +++ b/test/src/message/msg_channel_test.dart @@ -0,0 +1,48 @@ +import 'package:dartssh2/src/message/msg_channel.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSH_Message_Channel_Open', () { + test('x11 encode/decode roundtrip', () { + final message = SSH_Message_Channel_Open.x11( + senderChannel: 7, + initialWindowSize: 1024, + maximumPacketSize: 32768, + originatorIP: '127.0.0.1', + originatorPort: 6123, + ); + + final decoded = SSH_Message_Channel_Open.decode(message.encode()); + + expect(decoded.channelType, 'x11'); + expect(decoded.senderChannel, 7); + expect(decoded.initialWindowSize, 1024); + expect(decoded.maximumPacketSize, 32768); + expect(decoded.originatorIP, '127.0.0.1'); + expect(decoded.originatorPort, 6123); + }); + }); + + group('SSH_Message_Channel_Request', () { + test('x11-req encode/decode roundtrip', () { + final message = SSH_Message_Channel_Request.x11( + recipientChannel: 5, + wantReply: true, + singleConnection: true, + x11AuthenticationProtocol: 'MIT-MAGIC-COOKIE-1', + x11AuthenticationCookie: 'deadbeef', + x11ScreenNumber: '0', + ); + + final decoded = SSH_Message_Channel_Request.decode(message.encode()); + + expect(decoded.requestType, SSHChannelRequestType.x11); + expect(decoded.recipientChannel, 5); + expect(decoded.wantReply, isTrue); + expect(decoded.singleConnection, isTrue); + expect(decoded.x11AuthenticationProtocol, 'MIT-MAGIC-COOKIE-1'); + expect(decoded.x11AuthenticationCookie, 'deadbeef'); + expect(decoded.x11ScreenNumber, '0'); + }); + }); +} From f1c6a02e44a67625539f9ae08b53bb1c209fa509 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:34:14 +0100 Subject: [PATCH 40/56] feat: add SSH identification customization and validation --- CHANGELOG.md | 16 ++-- README.md | 18 ++++ lib/src/ssh_client.dart | 24 ++++- test/src/ssh_client_ident_test.dart | 130 ++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 test/src/ssh_client_ident_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index bf57a0b..92a3bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ ## [2.15.0] - yyyy-mm-dd -- Updated `pointycastle` dependency to `^4.0.0` [#131]. Thanks @vicajilau. -- Added foundational X11 forwarding support with session x11-req API, incoming x11 channel handling, and protocol tests [#1]. Thanks @vicajilau. +- Updated `pointycastle` dependency to `^4.0.0` [#131]. Thanks [@vicajilau]. +- Added foundational X11 forwarding support with session x11-req API, incoming x11 channel handling, and protocol tests [#1]. Thanks [@vicajilau]. +- Exposed SSH ident configuration from `SSHClient` [#135]. Thanks [@Remulic] and [@vicajilau]. ## [2.14.0] - 2026-03-19 -- Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks @shihuili1218. -- Adds a new forwardLocalUnix() function, which is an equivalent of ssh -L localPort:remoteSocketPath [#140]. Thanks @isegal. +- Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks [@shihuili1218]. +- Adds a new forwardLocalUnix() function, which is an equivalent of ssh -L localPort:remoteSocketPath [#140]. Thanks [@isegal]. ## [2.13.0] - 2025-06-22 - docs: Update NoPorts naming [#115]. [@XavierChanth]. @@ -179,6 +180,10 @@ - Initial release. +[#141]: https://github.com/TerminalStudio/dartssh2/pull/141 +[#140]: https://github.com/TerminalStudio/dartssh2/pull/140 +[#135]: https://github.com/TerminalStudio/dartssh2/pull/135 +[#131]: https://github.com/TerminalStudio/dartssh2/pull/131 [#127]: https://github.com/TerminalStudio/dartssh2/pull/127 [#126]: https://github.com/TerminalStudio/dartssh2/pull/126 [#125]: https://github.com/TerminalStudio/dartssh2/pull/125 @@ -201,4 +206,5 @@ [@XavierChanth]: https://github.com/XavierChanth [@MarBazuz]: https://github.com/MarBazuz [@reinbeumer]: https://github.com/reinbeumer -[@alexander-irion]: https://github.com/alexander-irion \ No newline at end of file +[@alexander-irion]: https://github.com/alexander-irion +[@Remulic]: https://github.com/Remulic \ No newline at end of file diff --git a/README.md b/README.md index 85bbaa3..4086fe3 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,24 @@ void main() async { > `SSHSocket` is an interface and it's possible to implement your own `SSHSocket` if you want to use a different underlying transport rather than standard TCP socket. For example WebSocket or Unix domain socket. +### Customize client SSH identification + +If your jump host or SSH gateway restricts client versions, you can customize the +software version part of the identification string (`SSH-2.0-`): + +```dart +void main() async { + final client = SSHClient( + await SSHSocket.connect('localhost', 22), + username: '', + onPasswordRequest: () => '', + ident: 'MyClient_1.0', + ); +} +``` + +`ident` defaults to `DartSSH_2.0`. + ### Spawn a shell on remote host ```dart diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index 2cdefd5..f120d18 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -187,8 +187,8 @@ class SSHClient { this.onX11Forward, this.keepAliveInterval = const Duration(seconds: 10), this.disableHostkeyVerification = false, - this.ident = 'DartSSH_2.0', - }) { + String ident = 'DartSSH_2.0', + }) : ident = _validateIdent(ident) { _transport = SSHTransport( socket, isServer: false, @@ -216,6 +216,26 @@ class SSHClient { } } + static String _validateIdent(String ident) { + if (ident.isEmpty) { + throw ArgumentError.value( + ident, + 'ident', + 'must not be empty', + ); + } + + if (ident.contains('\r') || ident.contains('\n')) { + throw ArgumentError.value( + ident, + 'ident', + 'must not contain carriage return or newline characters', + ); + } + + return ident; + } + late final SSHTransport _transport; /// A [Completer] that completes when the client has authenticated, or diff --git a/test/src/ssh_client_ident_test.dart b/test/src/ssh_client_ident_test.dart new file mode 100644 index 0000000..ea6c57b --- /dev/null +++ b/test/src/ssh_client_ident_test.dart @@ -0,0 +1,130 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSHClient.ident', () { + test('uses default ident when not provided', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient( + socket, + username: 'demo', + ); + + await Future.delayed(Duration.zero); + + expect(client.ident, 'DartSSH_2.0'); + expect(socket.writes, contains('SSH-2.0-DartSSH_2.0\r\n')); + + client.close(); + }); + + test('uses custom ident when provided', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient( + socket, + username: 'demo', + ident: 'MyClient_1.0', + ); + + await Future.delayed(Duration.zero); + + expect(client.ident, 'MyClient_1.0'); + expect(socket.writes, contains('SSH-2.0-MyClient_1.0\r\n')); + + client.close(); + }); + + test('throws when ident is empty', () { + expect( + () => SSHClient( + _FakeSSHSocket(), + username: 'demo', + ident: '', + ), + throwsA(isA()), + ); + }); + + test('throws when ident contains newline characters', () { + expect( + () => SSHClient( + _FakeSSHSocket(), + username: 'demo', + ident: 'Bad\nIdent', + ), + throwsA(isA()), + ); + + expect( + () => SSHClient( + _FakeSSHSocket(), + username: 'demo', + ident: 'Bad\rIdent', + ), + throwsA(isA()), + ); + }); + }); +} + +class _FakeSSHSocket implements SSHSocket { + final _inputController = StreamController(); + final _doneCompleter = Completer(); + final writes = []; + + @override + Stream get stream => _inputController.stream; + + @override + StreamSink> get sink => _RecordingSink(writes); + + @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> { + _RecordingSink(this._writes); + + final List _writes; + + @override + void add(List data) { + _writes.add(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 97d0bdf41938983be500d8d637f4a680698cbd37 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:43:16 +0100 Subject: [PATCH 41/56] fix: keep SSHAuthAbortError reason backward-compatible --- lib/src/ssh_client.dart | 5 ++--- lib/src/ssh_errors.dart | 4 ++-- test/src/ssh_client_test.dart | 2 -- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index 42b9ac2..2e950fe 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -568,12 +568,11 @@ class SSHClient { _requestAuthentication(); } - - void _handleTransportClosed(SSHError? error) { + void _handleTransportClosed(Object? error) { printDebug?.call('SSHClient._onTransportClosed'); if (!_authenticated.isCompleted) { _authenticated.completeError( - SSHAuthAbortError('Connection closed before authentication', error) + SSHAuthAbortError('Connection closed before authentication', error), ); } _keepAlive?.stop(); diff --git a/lib/src/ssh_errors.dart b/lib/src/ssh_errors.dart index 11c6c94..a80f59e 100644 --- a/lib/src/ssh_errors.dart +++ b/lib/src/ssh_errors.dart @@ -50,9 +50,9 @@ class SSHAuthAbortError with SSHMessageError implements SSHAuthError { @override final String message; - final SSHError? reason; + final Object? reason; - SSHAuthAbortError(this.message, this.reason); + SSHAuthAbortError(this.message, [this.reason]); } /// Errors that happen when the library receives an malformed packet. diff --git a/test/src/ssh_client_test.dart b/test/src/ssh_client_test.dart index 38171da..b1ece2d 100644 --- a/test/src/ssh_client_test.dart +++ b/test/src/ssh_client_test.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; From 8f585c3a52997f7dcf726d8bfe285efe84f40740 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:44:43 +0100 Subject: [PATCH 42/56] refactor: keep SSHAuthAbortError reason typed as SSHError --- lib/src/ssh_client.dart | 6 ++++-- lib/src/ssh_errors.dart | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index 2e950fe..a3c6b2f 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -204,7 +204,9 @@ class SSHClient { _transport.done.then( (_) => _handleTransportClosed(null), - onError: (e) => _handleTransportClosed(e), + onError: (e) => _handleTransportClosed( + e is SSHError ? e : SSHSocketError(e), + ), ); _authenticated.future.catchError( @@ -568,7 +570,7 @@ class SSHClient { _requestAuthentication(); } - void _handleTransportClosed(Object? error) { + void _handleTransportClosed(SSHError? error) { printDebug?.call('SSHClient._onTransportClosed'); if (!_authenticated.isCompleted) { _authenticated.completeError( diff --git a/lib/src/ssh_errors.dart b/lib/src/ssh_errors.dart index a80f59e..fd985ce 100644 --- a/lib/src/ssh_errors.dart +++ b/lib/src/ssh_errors.dart @@ -50,7 +50,7 @@ class SSHAuthAbortError with SSHMessageError implements SSHAuthError { @override final String message; - final Object? reason; + final SSHError? reason; SSHAuthAbortError(this.message, [this.reason]); } From c73966f443bf0a5e31f509ccdc70d1039678b08c Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:50:16 +0100 Subject: [PATCH 43/56] chore: update CHANGELOG to include SSHAuthAbortError reason propagation --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92a3bd9..e393aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Updated `pointycastle` dependency to `^4.0.0` [#131]. Thanks [@vicajilau]. - Added foundational X11 forwarding support with session x11-req API, incoming x11 channel handling, and protocol tests [#1]. Thanks [@vicajilau]. - Exposed SSH ident configuration from `SSHClient` [#135]. Thanks [@Remulic] and [@vicajilau]. +- Propagated the underlying exception in `SSHAuthAbortError` through `reason` for better diagnostics [#133]. Thanks [@james-thorpe] and [@vicajilau]. ## [2.14.0] - 2026-03-19 - Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks [@shihuili1218]. @@ -182,6 +183,7 @@ [#141]: https://github.com/TerminalStudio/dartssh2/pull/141 [#140]: https://github.com/TerminalStudio/dartssh2/pull/140 +[#133]: https://github.com/TerminalStudio/dartssh2/pull/133 [#135]: https://github.com/TerminalStudio/dartssh2/pull/135 [#131]: https://github.com/TerminalStudio/dartssh2/pull/131 [#127]: https://github.com/TerminalStudio/dartssh2/pull/127 @@ -207,4 +209,5 @@ [@MarBazuz]: https://github.com/MarBazuz [@reinbeumer]: https://github.com/reinbeumer [@alexander-irion]: https://github.com/alexander-irion -[@Remulic]: https://github.com/Remulic \ No newline at end of file +[@Remulic]: https://github.com/Remulic +[@james-thorpe]: https://github.com/james-thorpe \ No newline at end of file From e1f37eadf724f34a6fc6335ab2291d3fd630f49b Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:52:29 +0100 Subject: [PATCH 44/56] test: add tests for SSHAuthAbortError and SSHClient auth abort reason handling --- test/src/ssh_auth_abort_error_test.dart | 102 ++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 test/src/ssh_auth_abort_error_test.dart diff --git a/test/src/ssh_auth_abort_error_test.dart b/test/src/ssh_auth_abort_error_test.dart new file mode 100644 index 0000000..e400574 --- /dev/null +++ b/test/src/ssh_auth_abort_error_test.dart @@ -0,0 +1,102 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSHAuthAbortError', () { + test('supports constructor without reason for backward compatibility', () { + final error = SSHAuthAbortError('aborted'); + + expect(error.message, 'aborted'); + expect(error.reason, isNull); + }); + + test('stores provided reason', () { + final reason = SSHSocketError('boom'); + final error = SSHAuthAbortError('aborted', reason); + + expect(error.reason, same(reason)); + }); + }); + + group('SSHClient auth abort reason', () { + test('is null when transport closes without underlying error', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient( + socket, + username: 'demo', + ); + + await socket.close(); + + await expectLater( + client.authenticated, + throwsA( + predicate((error) { + return error is SSHAuthAbortError && error.reason == null; + }), + ), + ); + + 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) { + // Transport writes SSH version banner here; decode to validate data is valid bytes. + 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 173a899cd4a2af413cc36ab083adfde90849555c Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:57:25 +0100 Subject: [PATCH 45/56] fix: improve readability of version check in SSHTransport --- lib/src/ssh_transport.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index 2e6f61c..93fc85b 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -373,7 +373,8 @@ class SSHTransport { } final versionString = bufferString.substring(0, index); - if (!(versionString.startsWith('SSH-2.0-') || versionString.startsWith('SSH-1.99-'))) { + if (!(versionString.startsWith('SSH-2.0-') || + versionString.startsWith('SSH-1.99-'))) { socket.sink.add(latin1.encode('Protocol mismatch\r\n')); throw SSHHandshakeError('Invalid version: $versionString'); } From 84a22626aa7e82c762620dd718d9e45ec0e4c705 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:01:26 +0100 Subject: [PATCH 46/56] feat: accept SSH-1.99 server banners as SSH-2 compatible and add regression tests --- CHANGELOG.md | 2 + lib/src/ssh_transport.dart | 1 + test/src/ssh_transport_version_test.dart | 116 +++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 test/src/ssh_transport_version_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e393aa6..98a8327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Added foundational X11 forwarding support with session x11-req API, incoming x11 channel handling, and protocol tests [#1]. Thanks [@vicajilau]. - Exposed SSH ident configuration from `SSHClient` [#135]. Thanks [@Remulic] and [@vicajilau]. - Propagated the underlying exception in `SSHAuthAbortError` through `reason` for better diagnostics [#133]. Thanks [@james-thorpe] and [@vicajilau]. +- Accepted `SSH-1.99-*` server banners as SSH-2 compatible during version exchange and added regression tests [#132]. Thanks [@james-thorpe] and [@vicajilau]. ## [2.14.0] - 2026-03-19 - Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks [@shihuili1218]. @@ -183,6 +184,7 @@ [#141]: https://github.com/TerminalStudio/dartssh2/pull/141 [#140]: https://github.com/TerminalStudio/dartssh2/pull/140 +[#132]: https://github.com/TerminalStudio/dartssh2/pull/132 [#133]: https://github.com/TerminalStudio/dartssh2/pull/133 [#135]: https://github.com/TerminalStudio/dartssh2/pull/135 [#131]: https://github.com/TerminalStudio/dartssh2/pull/131 diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index 93fc85b..6df9e9c 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -373,6 +373,7 @@ class SSHTransport { } final versionString = bufferString.substring(0, index); + // RFC compatibility: SSH-1.99 banners indicate SSH-2 support with SSH-1 fallback. if (!(versionString.startsWith('SSH-2.0-') || versionString.startsWith('SSH-1.99-'))) { socket.sink.add(latin1.encode('Protocol mismatch\r\n')); diff --git a/test/src/ssh_transport_version_test.dart b/test/src/ssh_transport_version_test.dart new file mode 100644 index 0000000..ce37450 --- /dev/null +++ b/test/src/ssh_transport_version_test.dart @@ -0,0 +1,116 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSH transport version exchange', () { + test('accepts SSH-1.99 server banner', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient( + socket, + username: 'demo', + ); + + socket.addIncoming('SSH-1.99-OpenSSH_3.6.1p2\r\n'); + await _pumpUntil(() => client.remoteVersion != null); + + expect(client.remoteVersion, 'SSH-1.99-OpenSSH_3.6.1p2'); + + client.close(); + }); + + test('rejects non SSH-2 compatible server banners', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient( + socket, + username: 'demo', + ); + + socket.addIncoming('SSH-1.5-OpenSSH_1.2\r\n'); + + await expectLater( + client.authenticated, + throwsA( + predicate((error) { + return error is SSHAuthAbortError && + error.reason is SSHHandshakeError; + }), + ), + ); + + client.close(); + }); + }); +} + +Future _pumpUntil(bool Function() condition) async { + for (var i = 0; i < 50; i++) { + if (condition()) { + return; + } + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('Timed out waiting for condition'); +} + +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; + + void addIncoming(String data) { + _inputController.add(Uint8List.fromList(latin1.encode(data))); + } + + @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) { + // SSHTransport writes protocol lines and packets to the sink. + 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 f9dc7b09579d2bb65c3d807f3e10fe68e1d8d4cb Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:20:49 +0100 Subject: [PATCH 47/56] feat: add SSH agent forwarding support with in-memory handling and RSA sign-request flag --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98a8327..2bc8edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Exposed SSH ident configuration from `SSHClient` [#135]. Thanks [@Remulic] and [@vicajilau]. - Propagated the underlying exception in `SSHAuthAbortError` through `reason` for better diagnostics [#133]. Thanks [@james-thorpe] and [@vicajilau]. - Accepted `SSH-1.99-*` server banners as SSH-2 compatible during version exchange and added regression tests [#132]. Thanks [@james-thorpe] and [@vicajilau]. +- Added SSH agent forwarding support (`auth-agent-req@openssh.com`) with in-memory agent handling and RSA sign-request flag support [#139]. Thanks [@Wackymax] and [@vicajilau]. ## [2.14.0] - 2026-03-19 - Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks [@shihuili1218]. @@ -184,6 +185,7 @@ [#141]: https://github.com/TerminalStudio/dartssh2/pull/141 [#140]: https://github.com/TerminalStudio/dartssh2/pull/140 +[#139]: https://github.com/TerminalStudio/dartssh2/pull/139 [#132]: https://github.com/TerminalStudio/dartssh2/pull/132 [#133]: https://github.com/TerminalStudio/dartssh2/pull/133 [#135]: https://github.com/TerminalStudio/dartssh2/pull/135 @@ -212,4 +214,5 @@ [@reinbeumer]: https://github.com/reinbeumer [@alexander-irion]: https://github.com/alexander-irion [@Remulic]: https://github.com/Remulic -[@james-thorpe]: https://github.com/james-thorpe \ No newline at end of file +[@james-thorpe]: https://github.com/james-thorpe +[@Wackymax]: https://github.com/Wackymax \ No newline at end of file From 55c36d071aec8848a8e0d8a1f27d648389358595 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:26:19 +0100 Subject: [PATCH 48/56] refactor: format code for better readability in _rsaKeyFrom method --- lib/src/ssh_agent.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/src/ssh_agent.dart b/lib/src/ssh_agent.dart index b89317f..7e48fba 100644 --- a/lib/src/ssh_agent.dart +++ b/lib/src/ssh_agent.dart @@ -105,10 +105,12 @@ class SSHKeyPairAgent implements SSHAgentHandler { asymmetric.RSAPrivateKey? _rsaKeyFrom(SSHKeyPair identity) { if (identity is OpenSSHRsaKeyPair) { - return asymmetric.RSAPrivateKey(identity.n, identity.d, identity.p, identity.q); + return asymmetric.RSAPrivateKey( + identity.n, identity.d, identity.p, identity.q); } if (identity is RsaPrivateKey) { - return asymmetric.RSAPrivateKey(identity.n, identity.d, identity.p, identity.q); + return asymmetric.RSAPrivateKey( + identity.n, identity.d, identity.p, identity.q); } return null; } From d1341debcfec99e8c547cde20920671997e223fe Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:32:46 +0100 Subject: [PATCH 49/56] feat: enhance SSH agent forwarding with error handling and add tests for request failures --- lib/src/ssh_client.dart | 15 ++++++++++++--- test/src/ssh_agent_test.dart | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index 2eda9b2..3d67130 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -390,7 +390,11 @@ class SSHClient { } if (agentHandler != null) { - await channelController.sendAgentForwardingRequest(); + final agentOk = await channelController.sendAgentForwardingRequest(); + if (!agentOk) { + channelController.close(); + throw SSHChannelRequestError('Failed to request agent forwarding'); + } } if (pty != null) { @@ -447,7 +451,11 @@ class SSHClient { } if (agentHandler != null) { - await channelController.sendAgentForwardingRequest(); + final agentOk = await channelController.sendAgentForwardingRequest(); + if (!agentOk) { + channelController.close(); + throw SSHChannelRequestError('Failed to request agent forwarding'); + } } if (pty != null) { @@ -911,7 +919,8 @@ class SSHClient { if (handler == null) { final reply = SSH_Message_Channel_Open_Failure( recipientChannel: message.senderChannel, - reasonCode: SSH_Message_Channel_Open_Failure.codeUnknownChannelType, + reasonCode: + SSH_Message_Channel_Open_Failure.codeAdministrativelyProhibited, description: 'agent forwarding not enabled', ); _sendMessage(reply); diff --git a/test/src/ssh_agent_test.dart b/test/src/ssh_agent_test.dart index 2f36987..0c69100 100644 --- a/test/src/ssh_agent_test.dart +++ b/test/src/ssh_agent_test.dart @@ -68,4 +68,37 @@ void main() { expect(signature.type, entry.value); } }); + + test('SSHKeyPairAgent returns failure for empty and unknown requests', + () async { + final identity = rsaIdentity(); + final agent = SSHKeyPairAgent([identity]); + + final emptyResponse = await agent.handleRequest(Uint8List(0)); + expect( + SSHMessageReader(emptyResponse).readUint8(), SSHAgentProtocol.failure); + + final unknownWriter = SSHMessageWriter(); + unknownWriter.writeUint8(255); + final unknownResponse = + await agent.handleRequest(unknownWriter.takeBytes()); + expect( + SSHMessageReader(unknownResponse).readUint8(), + SSHAgentProtocol.failure, + ); + }); + + test('SSHKeyPairAgent returns failure when signing with unknown identity', + () async { + final agent = SSHKeyPairAgent([rsaIdentity()]); + final writer = SSHMessageWriter(); + writer.writeUint8(SSHAgentProtocol.signRequest); + writer.writeString(Uint8List.fromList([0, 1, 2, 3])); + writer.writeString(Uint8List.fromList('sign-me'.codeUnits)); + writer.writeUint32(SSHAgentProtocol.rsaSha2_256); + + final response = await agent.handleRequest(writer.takeBytes()); + + expect(SSHMessageReader(response).readUint8(), SSHAgentProtocol.failure); + }); } From 91e1804a3fa00a2e9be803c1f1dddedd16a23d91 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:34:49 +0100 Subject: [PATCH 50/56] test: add tests for SSHAgentChannel handling fragmented and back-to-back frames --- test/src/ssh_agent_test.dart | 138 +++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/test/src/ssh_agent_test.dart b/test/src/ssh_agent_test.dart index 0c69100..56913af 100644 --- a/test/src/ssh_agent_test.dart +++ b/test/src/ssh_agent_test.dart @@ -1,12 +1,28 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:dartssh2/dartssh2.dart'; import 'package:dartssh2/src/hostkey/hostkey_rsa.dart'; +import 'package:dartssh2/src/message/msg_channel.dart'; +import 'package:dartssh2/src/ssh_channel.dart'; import 'package:dartssh2/src/ssh_message.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; +class _RecordingAgentHandler implements SSHAgentHandler { + _RecordingAgentHandler(this.response); + + final Uint8List response; + final requests = []; + + @override + Future handleRequest(Uint8List request) async { + requests.add(request); + return response; + } +} + void main() { final rsaPrivate = fixture('ssh-rsa/id_rsa'); @@ -101,4 +117,126 @@ void main() { expect(SSHMessageReader(response).readUint8(), SSHAgentProtocol.failure); }); + + test('SSHAgentChannel handles fragmented request frames', () async { + final sentMessages = []; + final dataMessage = Completer(); + final handler = _RecordingAgentHandler(Uint8List.fromList([42, 43, 44])); + + final controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024, + localInitialWindowSize: 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024, + remoteInitialWindowSize: 1024, + sendMessage: (message) { + sentMessages.add(message); + if (message is SSH_Message_Channel_Data && !dataMessage.isCompleted) { + dataMessage.complete(message); + } + }, + ); + + SSHAgentChannel(controller.channel, handler); + + final requestPayload = Uint8List.fromList([ + SSHAgentProtocol.requestIdentities, + ]); + final requestWriter = SSHMessageWriter(); + requestWriter.writeUint32(requestPayload.length); + requestWriter.writeBytes(requestPayload); + final framedRequest = requestWriter.takeBytes(); + + controller.handleMessage( + SSH_Message_Channel_Data( + recipientChannel: controller.localId, + data: framedRequest.sublist(0, 2), + ), + ); + + controller.handleMessage( + SSH_Message_Channel_Data( + recipientChannel: controller.localId, + data: framedRequest.sublist(2), + ), + ); + + final responseMessage = await dataMessage.future; + expect(handler.requests.length, 1); + expect(handler.requests.single, requestPayload); + + final responseReader = SSHMessageReader(responseMessage.data); + expect(responseReader.readUint32(), 3); + expect(responseReader.readBytes(3), Uint8List.fromList([42, 43, 44])); + + expect( + sentMessages.whereType().length, + 1, + ); + + controller.destroy(); + }); + + test('SSHAgentChannel handles back-to-back frames in one chunk', () async { + final dataMessages = []; + final twoResponses = Completer(); + final handler = _RecordingAgentHandler(Uint8List.fromList([7, 8])); + + final controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024, + localInitialWindowSize: 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024, + remoteInitialWindowSize: 1024, + sendMessage: (message) { + if (message is SSH_Message_Channel_Data) { + dataMessages.add(message); + if (dataMessages.length == 2 && !twoResponses.isCompleted) { + twoResponses.complete(); + } + } + }, + ); + + SSHAgentChannel(controller.channel, handler); + + Uint8List frame(Uint8List payload) { + final writer = SSHMessageWriter(); + writer.writeUint32(payload.length); + writer.writeBytes(payload); + return writer.takeBytes(); + } + + final requestA = Uint8List.fromList([SSHAgentProtocol.requestIdentities]); + final requestB = Uint8List.fromList([SSHAgentProtocol.signRequest]); + + final frameA = frame(requestA); + final frameB = frame(requestB); + final chunk = Uint8List(frameA.length + frameB.length) + ..setAll(0, frameA) + ..setAll(frameA.length, frameB); + + controller.handleMessage( + SSH_Message_Channel_Data( + recipientChannel: controller.localId, + data: chunk, + ), + ); + + await twoResponses.future; + + expect(handler.requests.length, 2); + expect(handler.requests[0], requestA); + expect(handler.requests[1], requestB); + + for (final response in dataMessages) { + final responseReader = SSHMessageReader(response.data); + expect(responseReader.readUint32(), 2); + expect(responseReader.readBytes(2), Uint8List.fromList([7, 8])); + } + + controller.destroy(); + }); } From 5ef6bf97c6e1288d23c7b97c94bcd3d2e032b7e3 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:35:05 +0100 Subject: [PATCH 51/56] test: add test for SSHAgentChannel to ignore incomplete frames on channel close --- test/src/ssh_agent_test.dart | 51 ++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/test/src/ssh_agent_test.dart b/test/src/ssh_agent_test.dart index 56913af..6a24535 100644 --- a/test/src/ssh_agent_test.dart +++ b/test/src/ssh_agent_test.dart @@ -239,4 +239,55 @@ void main() { controller.destroy(); }); + + test('SSHAgentChannel ignores incomplete frame when channel closes', + () async { + final dataMessages = []; + final handler = _RecordingAgentHandler(Uint8List.fromList([1])); + + final controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024, + localInitialWindowSize: 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024, + remoteInitialWindowSize: 1024, + sendMessage: (message) { + if (message is SSH_Message_Channel_Data) { + dataMessages.add(message); + } + }, + ); + + SSHAgentChannel(controller.channel, handler); + + final truncatedFrame = Uint8List.fromList([ + 0, + 0, + 0, + 10, + SSHAgentProtocol.requestIdentities, + ]); + + controller.handleMessage( + SSH_Message_Channel_Data( + recipientChannel: controller.localId, + data: truncatedFrame, + ), + ); + + controller.handleMessage( + SSH_Message_Channel_EOF(recipientChannel: controller.localId), + ); + controller.handleMessage( + SSH_Message_Channel_Close(recipientChannel: controller.localId), + ); + + await Future.delayed(Duration.zero); + + expect(handler.requests, isEmpty); + expect(dataMessages, isEmpty); + + controller.destroy(); + }); } From 39f672e9e41c60a49a9c08364584725b227e8f2e Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:28:53 +0100 Subject: [PATCH 52/56] test: raise coverage and split unit/integration suites --- .github/workflows/dart.yml | 13 +- .gitignore | 4 +- dart_test.yaml | 3 + lib/src/http/http_client.dart | 17 +- lib/src/sftp/sftp_packet.dart | 4 +- test/src/channel/ssh_channel_test.dart | 3 + test/src/http/http_client_test.dart | 177 +++++++++++ test/src/message/msg_channel_test.dart | 298 +++++++++++++++++++ test/src/sftp/sftp_client_protocol_test.dart | 159 ++++++++++ test/src/sftp/sftp_client_test.dart | 3 + test/src/sftp/sftp_errors_test.dart | 77 +++++ test/src/sftp/sftp_file_attrs_test.dart | 97 ++++++ test/src/sftp/sftp_name_test.dart | 28 ++ test/src/sftp/sftp_packet_ext_test.dart | 51 ++++ test/src/sftp/sftp_packet_test.dart | 122 ++++++++ test/src/sftp/sftp_stream_io_test.dart | 3 + test/src/socket/ssh_socket_io_test.dart | 3 + test/src/ssh_client_test.dart | 3 + 18 files changed, 1052 insertions(+), 13 deletions(-) create mode 100644 dart_test.yaml create mode 100644 test/src/http/http_client_test.dart create mode 100644 test/src/sftp/sftp_client_protocol_test.dart create mode 100644 test/src/sftp/sftp_errors_test.dart create mode 100644 test/src/sftp/sftp_file_attrs_test.dart create mode 100644 test/src/sftp/sftp_name_test.dart create mode 100644 test/src/sftp/sftp_packet_ext_test.dart create mode 100644 test/src/sftp/sftp_packet_test.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index f3c339d..9f32d1a 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -40,14 +40,21 @@ jobs: - name: Activate coverage tool run: dart pub global activate coverage - - name: Run tests - run: dart pub global run coverage:test_with_coverage + - name: Run unit tests with coverage + run: dart test --exclude-tags=integration --coverage=coverage + + - name: Build LCOV report + run: dart pub global run coverage:format_coverage --lcov --in=coverage/test --out=coverage/lcov.info --packages=.dart_tool/package_config.json --report-on=lib + + - name: Run integration tests + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + run: dart test --tags=integration - name: Upload coverage uses: codecov/codecov-action@v4 with: fail_ci_if_error: true # optional (default = false) - files: ./coverage1.xml,./coverage2.xml # optional + files: ./coverage/lcov.info # optional flags: unittests # optional name: codecov-umbrella # optional token: ${{ secrets.CODECOV_TOKEN }} # required diff --git a/.gitignore b/.gitignore index 336b955..696f310 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ doc/api/ .history -.vscode \ No newline at end of file +.vscode + +coverage/ \ No newline at end of file diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..49921b8 --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,3 @@ +tags: + integration: + timeout: 2x diff --git a/lib/src/http/http_client.dart b/lib/src/http/http_client.dart index be3f972..19c2de2 100644 --- a/lib/src/http/http_client.dart +++ b/lib/src/http/http_client.dart @@ -415,20 +415,22 @@ class SSHHttpClientResponse { var contentRead = 0; void processLine(String line, int bytesRead, LineDecoder decoder) { + final normalizedLine = line.trimRight(); if (inBody) { body.write(line); contentRead += bytesRead; } else if (inHeader) { - if (line.trim().isEmpty) { + if (normalizedLine.trim().isEmpty) { inBody = true; if (contentLength > 0) { decoder.expectedByteCount = contentLength; } return; } - final separator = line.indexOf(':'); - final name = line.substring(0, separator).toLowerCase().trim(); - final value = line.substring(separator + 1).trim(); + final separator = normalizedLine.indexOf(':'); + final name = + normalizedLine.substring(0, separator).toLowerCase().trim(); + final value = normalizedLine.substring(separator + 1).trim(); if (name == SSHHttpHeaders.transferEncodingHeader && value.toLowerCase() != 'identity') { throw UnsupportedError('only identity transfer encoding is accepted'); @@ -440,11 +442,12 @@ class SSHHttpClientResponse { headers[name] = []; } headers[name]!.add(value); - } else if (line.startsWith('HTTP/1.1') || line.startsWith('HTTP/1.0')) { + } else if (normalizedLine.startsWith('HTTP/1.1') || + normalizedLine.startsWith('HTTP/1.0')) { statusCode = int.parse( - line.substring('HTTP/1.x '.length, 'HTTP/1.x xxx'.length), + normalizedLine.substring('HTTP/1.x '.length, 'HTTP/1.x xxx'.length), ); - reasonPhrase = line.substring('HTTP/1.x xxx '.length); + reasonPhrase = normalizedLine.substring('HTTP/1.x xxx '.length); inHeader = true; } else { throw UnsupportedError('unsupported http response format'); diff --git a/lib/src/sftp/sftp_packet.dart b/lib/src/sftp/sftp_packet.dart index 7e81877..2121ed2 100644 --- a/lib/src/sftp/sftp_packet.dart +++ b/lib/src/sftp/sftp_packet.dart @@ -60,7 +60,7 @@ class SftpInitPacket implements SftpPacket { reader.readUint8(); // packet type final version = reader.readUint32(); final extensions = {}; - while (reader.isDone) { + while (!reader.isDone) { final name = reader.readUtf8(); final value = reader.readUtf8(); extensions[name] = value; @@ -1031,7 +1031,7 @@ class SftpExtendedReplyPacket implements SftpResponsePacket { final writer = SSHMessageWriter(); writer.writeUint8(packetType); writer.writeUint32(requestId); - writer.writeString(payload); + writer.writeBytes(payload); return writer.takeBytes(); } diff --git a/test/src/channel/ssh_channel_test.dart b/test/src/channel/ssh_channel_test.dart index ac3a09f..17183e1 100644 --- a/test/src/channel/ssh_channel_test.dart +++ b/test/src/channel/ssh_channel_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library ssh_channel_test; + import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/http/http_client_test.dart b/test/src/http/http_client_test.dart new file mode 100644 index 0000000..91c9ebf --- /dev/null +++ b/test/src/http/http_client_test.dart @@ -0,0 +1,177 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/src/http/http_client.dart'; +import 'package:dartssh2/src/http/http_exception.dart'; +import 'package:dartssh2/src/socket/ssh_socket.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSHHttpClientResponse.from', () { + test('parses status line, headers and body', () async { + final socket = _FakeSocket([ + 'HTTP/1.1 200 OK\r\n', + 'content-length: 5\r\n', + 'content-type: text/plain; charset=utf-8\r\n', + '\r\n', + 'hello', + ]); + + final response = await SSHHttpClientResponse.from(socket); + + expect(response.statusCode, 200); + expect(response.reasonPhrase, 'OK'); + expect(response.body, 'hello'); + expect(response.headers.contentLength, 5); + expect(response.headers.contentType?.mimeType, 'text/plain'); + expect(socket.closed, isTrue); + }); + + test('supports HTTP/1.0 responses', () async { + final socket = _FakeSocket([ + 'HTTP/1.0 404 Not Found\r\n', + 'content-length: 0\r\n', + '\r\n', + ]); + + final response = await SSHHttpClientResponse.from(socket); + + expect(response.statusCode, 404); + expect(response.reasonPhrase, 'Not Found'); + expect(response.body, isEmpty); + }); + + test('throws for non-identity transfer encoding', () async { + final socket = _FakeSocket([ + 'HTTP/1.1 200 OK\r\n', + 'transfer-encoding: chunked\r\n', + 'content-length: 0\r\n', + '\r\n', + ]); + + await expectLater( + SSHHttpClientResponse.from(socket), + throwsA(isA()), + ); + }); + + test('throws for unsupported response format', () async { + final socket = _FakeSocket([ + 'NOT_HTTP\r\n', + ]); + + await expectLater( + SSHHttpClientResponse.from(socket), + throwsA(isA()), + ); + }); + }); + + group('SSHHttpClientResponse headers', () { + test('throws when reading duplicated header via value()', () async { + final socket = _FakeSocket([ + 'HTTP/1.1 200 OK\r\n', + 'x-test: a\r\n', + 'x-test: b\r\n', + 'content-length: 0\r\n', + '\r\n', + ]); + + final response = await SSHHttpClientResponse.from(socket); + + expect( + () => response.headers.value('x-test'), + throwsA(isA()), + ); + }); + + test('parses date-like headers and exposes raw host header', () async { + final socket = _FakeSocket([ + 'HTTP/1.1 200 OK\r\n', + 'host: localhost:8080\r\n', + 'date: 2024-01-01T10:00:00.000Z\r\n', + 'expires: 2024-01-01T12:00:00.000Z\r\n', + 'if-modified-since: 2024-01-01T09:00:00.000Z\r\n', + 'content-length: 0\r\n', + '\r\n', + ]); + + final response = await SSHHttpClientResponse.from(socket); + + expect(response.headers.value('host'), 'localhost:8080'); + expect(response.headers.date, DateTime.parse('2024-01-01T10:00:00.000Z')); + expect( + response.headers.expires, DateTime.parse('2024-01-01T12:00:00.000Z')); + expect( + response.headers.ifModifiedSince, + DateTime.parse('2024-01-01T09:00:00.000Z'), + ); + }); + + test('response headers are immutable', () async { + final socket = _FakeSocket([ + 'HTTP/1.1 200 OK\r\n', + 'content-length: 0\r\n', + '\r\n', + ]); + + final response = await SSHHttpClientResponse.from(socket); + + expect( + () => response.headers.add('x', '1'), + throwsA(isA()), + ); + expect( + () => response.headers.set('x', '1'), + throwsA(isA()), + ); + expect( + () => response.headers.removeAll('x'), + throwsA(isA()), + ); + expect( + () => response.headers.clear(), + throwsA(isA()), + ); + }); + }); +} + +class _FakeSocket implements SSHSocket { + _FakeSocket(List chunks) + : _chunks = chunks + .map((chunk) => Uint8List.fromList(chunk.codeUnits)) + .toList(growable: false); + + final List _chunks; + final _sinkController = StreamController>(); + final _doneCompleter = Completer(); + bool closed = false; + + @override + Stream get stream => Stream.fromIterable(_chunks); + + @override + StreamSink> get sink => _sinkController.sink; + + @override + Future get done => _doneCompleter.future; + + @override + Future close() async { + closed = true; + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + await _sinkController.close(); + } + + @override + void destroy() { + closed = true; + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + unawaited(_sinkController.close()); + } +} diff --git a/test/src/message/msg_channel_test.dart b/test/src/message/msg_channel_test.dart index 12b08f7..3d16202 100644 --- a/test/src/message/msg_channel_test.dart +++ b/test/src/message/msg_channel_test.dart @@ -1,4 +1,7 @@ +import 'dart:typed_data'; + import 'package:dartssh2/src/message/msg_channel.dart'; +import 'package:dartssh2/src/ssh_message.dart'; import 'package:test/test.dart'; void main() { @@ -21,6 +24,169 @@ void main() { expect(decoded.originatorIP, '127.0.0.1'); expect(decoded.originatorPort, 6123); }); + + test('direct-streamlocal encode/decode roundtrip', () { + final message = SSH_Message_Channel_Open.directStreamLocal( + senderChannel: 11, + initialWindowSize: 2048, + maximumPacketSize: 32768, + socketPath: '/var/run/docker.sock', + ); + + final decoded = SSH_Message_Channel_Open.decode(message.encode()); + + expect(decoded.channelType, 'direct-streamlocal@openssh.com'); + expect(decoded.senderChannel, 11); + expect(decoded.initialWindowSize, 2048); + expect(decoded.maximumPacketSize, 32768); + expect(decoded.socketPath, '/var/run/docker.sock'); + }); + + test('direct-tcpip encode/decode roundtrip', () { + final message = SSH_Message_Channel_Open.directTcpip( + senderChannel: 9, + initialWindowSize: 4096, + maximumPacketSize: 32768, + host: 'example.com', + port: 443, + originatorIP: '10.0.0.5', + originatorPort: 5111, + ); + + final decoded = SSH_Message_Channel_Open.decode(message.encode()); + + expect(decoded.channelType, 'direct-tcpip'); + expect(decoded.host, 'example.com'); + expect(decoded.port, 443); + expect(decoded.originatorIP, '10.0.0.5'); + expect(decoded.originatorPort, 5111); + }); + + test('forwarded-tcpip encode/decode roundtrip', () { + final message = SSH_Message_Channel_Open.forwardedTcpip( + senderChannel: 10, + initialWindowSize: 4096, + maximumPacketSize: 32768, + host: '127.0.0.1', + port: 8080, + originatorIP: '192.168.1.2', + originatorPort: 1234, + ); + + final decoded = SSH_Message_Channel_Open.decode(message.encode()); + + expect(decoded.channelType, 'forwarded-tcpip'); + expect(decoded.host, '127.0.0.1'); + expect(decoded.port, 8080); + expect(decoded.originatorIP, '192.168.1.2'); + expect(decoded.originatorPort, 1234); + }); + + test('session encode/decode roundtrip', () { + final message = SSH_Message_Channel_Open.session( + senderChannel: 2, + initialWindowSize: 2048, + maximumPacketSize: 16384, + ); + + final decoded = SSH_Message_Channel_Open.decode(message.encode()); + + expect(decoded.channelType, 'session'); + expect(decoded.senderChannel, 2); + expect(decoded.initialWindowSize, 2048); + expect(decoded.maximumPacketSize, 16384); + }); + }); + + group('SSH_Message_Channel_Generic', () { + test('confirmation encode/decode roundtrip', () { + final message = SSH_Message_Channel_Confirmation( + recipientChannel: 1, + senderChannel: 2, + initialWindowSize: 1024, + maximumPacketSize: 2048, + data: Uint8List.fromList([4, 5, 6]), + ); + + final decoded = SSH_Message_Channel_Confirmation.decode(message.encode()); + + expect(decoded.recipientChannel, 1); + expect(decoded.senderChannel, 2); + expect(decoded.initialWindowSize, 1024); + expect(decoded.maximumPacketSize, 2048); + expect(decoded.data, Uint8List.fromList([4, 5, 6])); + }); + + test('open failure encode/decode roundtrip', () { + final message = SSH_Message_Channel_Open_Failure( + recipientChannel: 3, + reasonCode: SSH_Message_Channel_Open_Failure.codeConnectFailed, + description: 'failed', + languageTag: 'en', + ); + + final decoded = SSH_Message_Channel_Open_Failure.decode(message.encode()); + + expect(decoded.recipientChannel, 3); + expect(decoded.reasonCode, + SSH_Message_Channel_Open_Failure.codeConnectFailed); + expect(decoded.description, 'failed'); + expect(decoded.languageTag, 'en'); + }); + + test('window adjust encode/decode roundtrip', () { + final message = SSH_Message_Channel_Window_Adjust( + recipientChannel: 7, + bytesToAdd: 8192, + ); + + final decoded = + SSH_Message_Channel_Window_Adjust.decode(message.encode()); + + expect(decoded.recipientChannel, 7); + expect(decoded.bytesToAdd, 8192); + }); + + test('data and extended data encode/decode roundtrip', () { + final data = SSH_Message_Channel_Data( + recipientChannel: 1, + data: Uint8List.fromList([1, 2, 3]), + ); + final dataDecoded = SSH_Message_Channel_Data.decode(data.encode()); + expect(dataDecoded.recipientChannel, 1); + expect(dataDecoded.data, Uint8List.fromList([1, 2, 3])); + + final ext = SSH_Message_Channel_Extended_Data( + recipientChannel: 1, + dataTypeCode: SSH_Message_Channel_Extended_Data.dataTypeStderr, + data: Uint8List.fromList([9, 8]), + ); + final extDecoded = SSH_Message_Channel_Extended_Data.decode(ext.encode()); + expect(extDecoded.recipientChannel, 1); + expect(extDecoded.dataTypeCode, + SSH_Message_Channel_Extended_Data.dataTypeStderr); + expect(extDecoded.data, Uint8List.fromList([9, 8])); + }); + + test('eof, close, success and failure encode/decode roundtrip', () { + final eof = SSH_Message_Channel_EOF(recipientChannel: 9); + final eofDecoded = SSH_Message_Channel_EOF.decode(eof.encode()); + expect(eofDecoded.recipientChannel, 9); + + final close = SSH_Message_Channel_Close(recipientChannel: 10); + final closeDecoded = SSH_Message_Channel_Close.decode(close.encode()); + expect(closeDecoded.recipientChannel, 10); + + final success = SSH_Message_Channel_Success(recipientChannel: 11); + final successDecoded = + SSH_Message_Channel_Success.decode(success.encode()); + expect(successDecoded.recipientChannel, 11); + + final failure = SSH_Message_Channel_Failure(recipientChannel: 12); + final failureDecoded = + SSH_Message_Channel_Failure.decode(failure.encode()); + expect(failureDecoded.recipientChannel, 12); + }); }); group('SSH_Message_Channel_Request', () { @@ -44,5 +210,137 @@ void main() { expect(decoded.x11AuthenticationCookie, 'deadbeef'); expect(decoded.x11ScreenNumber, '0'); }); + + test('auth-agent request encode/decode roundtrip', () { + final message = SSH_Message_Channel_Request( + recipientChannel: 3, + requestType: SSHChannelRequestType.authAgent, + wantReply: true, + ); + + final decoded = SSH_Message_Channel_Request.decode(message.encode()); + + expect(decoded.requestType, SSHChannelRequestType.authAgent); + expect(decoded.recipientChannel, 3); + expect(decoded.wantReply, isTrue); + }); + + test('pty request encode/decode roundtrip', () { + final message = SSH_Message_Channel_Request.pty( + recipientChannel: 9, + wantReply: true, + termType: 'xterm-256color', + termWidth: 120, + termHeight: 40, + termPixelWidth: 0, + termPixelHeight: 0, + termModes: Uint8List.fromList([0]), + ); + + final decoded = SSH_Message_Channel_Request.decode(message.encode()); + + expect(decoded.requestType, SSHChannelRequestType.pty); + expect(decoded.termType, 'xterm-256color'); + expect(decoded.termWidth, 120); + expect(decoded.termHeight, 40); + }); + + test('env, exec and subsystem requests roundtrip', () { + final env = SSH_Message_Channel_Request.env( + recipientChannel: 1, + wantReply: true, + variableName: 'LANG', + variableValue: 'en_US.UTF-8', + ); + final envDecoded = SSH_Message_Channel_Request.decode(env.encode()); + expect(envDecoded.requestType, SSHChannelRequestType.env); + expect(envDecoded.variableName, 'LANG'); + expect(envDecoded.variableValue, 'en_US.UTF-8'); + + final exec = SSH_Message_Channel_Request.exec( + recipientChannel: 1, + wantReply: true, + command: 'ls -la', + ); + final execDecoded = SSH_Message_Channel_Request.decode(exec.encode()); + expect(execDecoded.requestType, SSHChannelRequestType.exec); + expect(execDecoded.command, 'ls -la'); + + final subsystem = SSH_Message_Channel_Request.subsystem( + recipientChannel: 1, + wantReply: true, + subsystemName: 'sftp', + ); + final subsystemDecoded = + SSH_Message_Channel_Request.decode(subsystem.encode()); + expect(subsystemDecoded.requestType, SSHChannelRequestType.subsystem); + expect(subsystemDecoded.subsystemName, 'sftp'); + }); + + test('window-change, signal and exit-status requests roundtrip', () { + final windowChange = SSH_Message_Channel_Request.windowChange( + recipientChannel: 2, + termWidth: 100, + termHeight: 30, + termPixelWidth: 800, + termPixelHeight: 600, + ); + final windowDecoded = + SSH_Message_Channel_Request.decode(windowChange.encode()); + expect(windowDecoded.requestType, SSHChannelRequestType.windowChange); + expect(windowDecoded.termWidth, 100); + expect(windowDecoded.termHeight, 30); + expect(windowDecoded.termPixelWidth, 800); + expect(windowDecoded.termPixelHeight, 600); + + final signal = SSH_Message_Channel_Request.signal( + recipientChannel: 2, + signalName: 'KILL', + ); + final signalDecoded = SSH_Message_Channel_Request.decode(signal.encode()); + expect(signalDecoded.requestType, SSHChannelRequestType.signal); + expect(signalDecoded.signalName, 'KILL'); + + final exitStatus = SSH_Message_Channel_Request.exitStatus( + recipientChannel: 2, + exitStatus: 127, + ); + final exitStatusDecoded = + SSH_Message_Channel_Request.decode(exitStatus.encode()); + expect(exitStatusDecoded.requestType, SSHChannelRequestType.exitStatus); + expect(exitStatusDecoded.exitStatus, 127); + }); + + test('exit-signal request encode/decode roundtrip', () { + final message = SSH_Message_Channel_Request.exitSignal( + recipientChannel: 6, + exitSignalName: 'TERM', + coreDumped: true, + errorMessage: 'terminated', + languageTag: 'en', + ); + + final decoded = SSH_Message_Channel_Request.decode(message.encode()); + + expect(decoded.requestType, SSHChannelRequestType.exitSignal); + expect(decoded.exitSignalName, 'TERM'); + expect(decoded.coreDumped, isTrue); + expect(decoded.errorMessage, 'terminated'); + expect(decoded.languageTag, 'en'); + }); + + test('unknown request type decodes as generic request', () { + final writer = SSHMessageWriter(); + writer.writeUint8(SSH_Message_Channel_Request.messageId); + writer.writeUint32(13); + writer.writeUtf8('custom-request'); + writer.writeBool(false); + + final decoded = SSH_Message_Channel_Request.decode(writer.takeBytes()); + + expect(decoded.recipientChannel, 13); + expect(decoded.requestType, 'custom-request'); + expect(decoded.wantReply, isFalse); + }); }); } diff --git a/test/src/sftp/sftp_client_protocol_test.dart b/test/src/sftp/sftp_client_protocol_test.dart new file mode 100644 index 0000000..00c2c3b --- /dev/null +++ b/test/src/sftp/sftp_client_protocol_test.dart @@ -0,0 +1,159 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/src/message/msg_channel.dart'; +import 'package:dartssh2/src/sftp/sftp_client.dart'; +import 'package:dartssh2/src/sftp/sftp_errors.dart'; +import 'package:dartssh2/src/sftp/sftp_file_attrs.dart'; +import 'package:dartssh2/src/sftp/sftp_packet.dart'; +import 'package:dartssh2/src/sftp/sftp_status_code.dart'; +import 'package:dartssh2/src/ssh_channel.dart'; +import 'package:dartssh2/src/ssh_message.dart'; +import 'package:test/test.dart'; + +void main() { + group('SftpClient protocol', () { + test('handshake completes with version packet', () async { + final harness = _SftpHarness(); + final initPayload = await harness.nextOutgoingPacket(); + final init = SftpInitPacket.decode(initPayload); + expect(init.version, 3); + + harness.sendResponsePacket( + SftpVersionPacket(3, {'fstatvfs@openssh.com': '2'}), + ); + + final handshake = await harness.client.handshake; + expect(handshake.version, 3); + expect(handshake.extensions['fstatvfs@openssh.com'], '2'); + + harness.dispose(); + }); + + test('stat uses lstat when followLink is false', () async { + final harness = _SftpHarness(); + await harness.nextOutgoingPacket(); + harness.sendResponsePacket(SftpVersionPacket(3)); + await harness.client.handshake; + + final statFuture = harness.client.stat('/tmp/file', followLink: false); + final packet = await harness.nextOutgoingPacket(); + final lstat = SftpLStatPacket.decode(packet); + + harness.sendResponsePacket( + SftpAttrsPacket( + lstat.requestId, + SftpFileAttrs(size: 55, mode: const SftpFileMode.value(1 << 15)), + ), + ); + + final attrs = await statFuture; + expect(attrs.size, 55); + expect(attrs.isFile, isTrue); + + harness.dispose(); + }); + + test('stat throws SftpStatusError on failure status', () async { + final harness = _SftpHarness(); + await harness.nextOutgoingPacket(); + harness.sendResponsePacket(SftpVersionPacket(3)); + await harness.client.handshake; + + final statFuture = harness.client.stat('/tmp/file'); + final packet = await harness.nextOutgoingPacket(); + final stat = SftpStatPacket.decode(packet); + + harness.sendResponsePacket( + SftpStatusPacket( + requestId: stat.requestId, + code: SftpStatusCode.permissionDenied, + message: 'denied', + ), + ); + + await expectLater( + statFuture, + throwsA( + isA() + .having((e) => e.code, 'code', SftpStatusCode.permissionDenied), + ), + ); + + harness.dispose(); + }); + + test('close aborts pending requests', () async { + final harness = _SftpHarness(); + await harness.nextOutgoingPacket(); + harness.sendResponsePacket(SftpVersionPacket(3)); + await harness.client.handshake; + + final openFuture = harness.client.open('/tmp/f'); + await harness.nextOutgoingPacket(); + + harness.client.close(); + + await expectLater(openFuture, throwsA(isA())); + harness.dispose(); + }); + }); +} + +class _SftpHarness { + _SftpHarness() { + _controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024 * 1024, + localInitialWindowSize: 1024 * 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024 * 1024, + remoteInitialWindowSize: 1024 * 1024, + sendMessage: _handleOutboundMessage, + ); + client = SftpClient(_controller.channel); + } + + late final SSHChannelController _controller; + late final SftpClient client; + + final _outgoing = StreamController.broadcast(); + var _disposed = false; + + void _handleOutboundMessage(SSHMessage message) { + if (message is! SSH_Message_Channel_Data) return; + final reader = SSHMessageReader(message.data); + final length = reader.readUint32(); + final payload = reader.readBytes(length); + _outgoing.add(payload); + } + + Future nextOutgoingPacket() => _outgoing.stream.first; + + void sendResponsePacket(SftpPacket packet) { + final payload = packet.encode(); + final writer = SSHMessageWriter(); + writer.writeUint32(payload.length); + writer.writeBytes(payload); + + _controller.handleMessage( + SSH_Message_Channel_Data( + recipientChannel: _controller.localId, + data: writer.takeBytes(), + ), + ); + } + + void dispose() { + if (_disposed) return; + _disposed = true; + + try { + client.close(); + } catch (_) { + // SftpClient.close is not idempotent when already completed with error. + } + _controller.destroy(); + _outgoing.close(); + } +} diff --git a/test/src/sftp/sftp_client_test.dart b/test/src/sftp/sftp_client_test.dart index 5368622..a9f84e0 100644 --- a/test/src/sftp/sftp_client_test.dart +++ b/test/src/sftp/sftp_client_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library sftp_client_test; + import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/sftp/sftp_errors_test.dart b/test/src/sftp/sftp_errors_test.dart new file mode 100644 index 0000000..d08a378 --- /dev/null +++ b/test/src/sftp/sftp_errors_test.dart @@ -0,0 +1,77 @@ +import 'package:dartssh2/src/sftp/sftp_errors.dart'; +import 'package:dartssh2/src/sftp/sftp_packet.dart'; +import 'package:dartssh2/src/sftp/sftp_status_code.dart'; +import 'package:test/test.dart'; + +void main() { + group('SftpError types', () { + test('toString includes message details', () { + expect(SftpError('oops').toString(), 'SftpError: oops'); + expect(SftpAbortError('abort').toString(), 'SftpAbortError: abort'); + expect( + SftpExtensionUnsupportedError('posix-rename@openssh.com').toString(), + contains('not supported'), + ); + expect( + SftpExtensionVersionMismatchError('copy-data', '2').toString(), + contains('version 2'), + ); + }); + + test('SftpStatusError.fromStatus maps code and message', () { + final status = SftpStatusPacket( + requestId: 1, + code: SftpStatusCode.permissionDenied, + message: 'denied', + ); + + final error = SftpStatusError.fromStatus(status); + + expect(error.code, SftpStatusCode.permissionDenied); + expect(error.message, 'denied'); + expect(error.toString(), + contains('code ${SftpStatusCode.permissionDenied}')); + }); + + test('SftpStatusError.check allows ok and eof', () { + expect( + () => SftpStatusError.check( + SftpStatusPacket( + requestId: 1, + code: SftpStatusCode.ok, + message: 'ok', + ), + ), + returnsNormally, + ); + + expect( + () => SftpStatusError.check( + SftpStatusPacket( + requestId: 2, + code: SftpStatusCode.eof, + message: 'eof', + ), + ), + returnsNormally, + ); + }); + + test('SftpStatusError.check throws for non-ok status', () { + expect( + () => SftpStatusError.check( + SftpStatusPacket( + requestId: 3, + code: SftpStatusCode.failure, + message: 'failed', + ), + ), + throwsA( + isA() + .having((e) => e.code, 'code', SftpStatusCode.failure) + .having((e) => e.message, 'message', 'failed'), + ), + ); + }); + }); +} diff --git a/test/src/sftp/sftp_file_attrs_test.dart b/test/src/sftp/sftp_file_attrs_test.dart new file mode 100644 index 0000000..bbd7706 --- /dev/null +++ b/test/src/sftp/sftp_file_attrs_test.dart @@ -0,0 +1,97 @@ +import 'package:dartssh2/src/sftp/sftp_file_attrs.dart'; +import 'package:dartssh2/src/ssh_message.dart'; +import 'package:test/test.dart'; + +void main() { + group('SftpFileMode', () { + test('factory sets expected permission flags', () { + final mode = SftpFileMode( + userRead: true, + userWrite: false, + userExecute: true, + groupRead: false, + groupWrite: true, + groupExecute: false, + otherRead: true, + otherWrite: false, + otherExecute: true, + ); + + expect(mode.userRead, isTrue); + expect(mode.userWrite, isFalse); + expect(mode.userExecute, isTrue); + expect(mode.groupRead, isFalse); + expect(mode.groupWrite, isTrue); + expect(mode.groupExecute, isFalse); + expect(mode.otherRead, isTrue); + expect(mode.otherWrite, isFalse); + expect(mode.otherExecute, isTrue); + }); + + test('type detection covers all known mode masks', () { + expect(const SftpFileMode.value(1 << 12).type, SftpFileType.pipe); + expect( + const SftpFileMode.value(1 << 13).type, + SftpFileType.characterDevice, + ); + expect(const SftpFileMode.value(1 << 14).type, SftpFileType.directory); + expect( + const SftpFileMode.value((1 << 14) + (1 << 13)).type, + SftpFileType.blockDevice, + ); + expect(const SftpFileMode.value(1 << 15).type, SftpFileType.regularFile); + expect( + const SftpFileMode.value((1 << 15) + (1 << 13)).type, + SftpFileType.symbolicLink, + ); + expect( + const SftpFileMode.value((1 << 15) + (1 << 14)).type, + SftpFileType.socket, + ); + expect( + const SftpFileMode.value((1 << 15) + (1 << 14) + (1 << 13)).type, + SftpFileType.whiteout, + ); + expect(const SftpFileMode.value(0).type, SftpFileType.unknown); + }); + }); + + group('SftpFileAttrs', () { + test('writeTo/readFrom roundtrip keeps all fields', () { + final attrs = SftpFileAttrs( + size: 987654321, + userID: 1001, + groupID: 1002, + mode: const SftpFileMode.value((1 << 14) + 0x1A4), + accessTime: 1700000000, + modifyTime: 1700000100, + extended: const {'keyA': 'valueA', 'keyB': 'valueB'}, + ); + + final writer = SSHMessageWriter(); + attrs.writeTo(writer); + + final reader = SSHMessageReader(writer.takeBytes()); + final decoded = SftpFileAttrs.readFrom(reader); + + expect(decoded.size, attrs.size); + expect(decoded.userID, attrs.userID); + expect(decoded.groupID, attrs.groupID); + expect(decoded.mode?.value, attrs.mode?.value); + expect(decoded.accessTime, attrs.accessTime); + expect(decoded.modifyTime, attrs.modifyTime); + expect(decoded.extended, attrs.extended); + }); + + test('type helper getters reflect mode type', () { + final attrs = SftpFileAttrs( + mode: const SftpFileMode.value((1 << 15) + (1 << 13)), + ); + + expect(attrs.isSymbolicLink, isTrue); + expect(attrs.isDirectory, isFalse); + expect(attrs.isFile, isFalse); + expect(attrs.type, SftpFileType.symbolicLink); + }); + }); +} diff --git a/test/src/sftp/sftp_name_test.dart b/test/src/sftp/sftp_name_test.dart new file mode 100644 index 0000000..ccc1e6e --- /dev/null +++ b/test/src/sftp/sftp_name_test.dart @@ -0,0 +1,28 @@ +import 'package:dartssh2/src/sftp/sftp_file_attrs.dart'; +import 'package:dartssh2/src/sftp/sftp_name.dart'; +import 'package:dartssh2/src/ssh_message.dart'; +import 'package:test/test.dart'; + +void main() { + test('SftpName writeTo/readFrom roundtrip', () { + final original = SftpName( + filename: 'report.txt', + longname: '-rw-r--r-- 1 user group 10 report.txt', + attr: SftpFileAttrs( + size: 10, + mode: const SftpFileMode.value((1 << 15) + 0x1A4), + ), + ); + + final writer = SSHMessageWriter(); + original.writeTo(writer); + + final reader = SSHMessageReader(writer.takeBytes()); + final decoded = SftpName.readFrom(reader); + + expect(decoded.filename, 'report.txt'); + expect(decoded.longname, '-rw-r--r-- 1 user group 10 report.txt'); + expect(decoded.attr.size, 10); + expect(decoded.attr.isFile, isTrue); + }); +} diff --git a/test/src/sftp/sftp_packet_ext_test.dart b/test/src/sftp/sftp_packet_ext_test.dart new file mode 100644 index 0000000..01718eb --- /dev/null +++ b/test/src/sftp/sftp_packet_ext_test.dart @@ -0,0 +1,51 @@ +import 'dart:typed_data'; + +import 'package:dartssh2/src/sftp/sftp_packet_ext.dart'; +import 'package:dartssh2/src/ssh_message.dart'; +import 'package:test/test.dart'; + +void main() { + group('SFTP extended payload', () { + test('statvfs request encodes name + path', () { + final request = SftpStatVfsRequest(path: '/tmp'); + final encoded = request.encode(); + final reader = SSHMessageReader(encoded); + + expect(reader.readUtf8(), 'statvfs@openssh.com'); + expect(reader.readUtf8(), '/tmp'); + expect(reader.isDone, isTrue); + }); + + test('fstatvfs request encodes name + handle', () { + final request = + SftpFstatVfsRequest(handle: Uint8List.fromList([1, 2, 3])); + final encoded = request.encode(); + final reader = SSHMessageReader(encoded); + + expect(reader.readUtf8(), 'fstatvfs@openssh.com'); + expect(reader.readString(), Uint8List.fromList([1, 2, 3])); + expect(reader.isDone, isTrue); + }); + + test('statvfs reply decodes all fields in order', () { + final writer = SSHMessageWriter(); + for (var i = 1; i <= 11; i++) { + writer.writeUint64(i); + } + + final reply = SftpStatVfsReply.decode(writer.takeBytes()); + + expect(reply.blockSize, 1); + expect(reply.fundamentalBlockSize, 2); + expect(reply.totalBlocks, 3); + expect(reply.freeBlocks, 4); + expect(reply.freeBlocksForNonRoot, 5); + expect(reply.totalInodes, 6); + expect(reply.freeInodes, 7); + expect(reply.freeInodesForNonRoot, 8); + expect(reply.fileSystemId, 9); + expect(reply.flag, 10); + expect(reply.maximumFilenameLength, 11); + }); + }); +} diff --git a/test/src/sftp/sftp_packet_test.dart b/test/src/sftp/sftp_packet_test.dart new file mode 100644 index 0000000..2fd67b7 --- /dev/null +++ b/test/src/sftp/sftp_packet_test.dart @@ -0,0 +1,122 @@ +import 'dart:typed_data'; + +import 'package:dartssh2/src/sftp/sftp_file_attrs.dart'; +import 'package:dartssh2/src/sftp/sftp_name.dart'; +import 'package:dartssh2/src/sftp/sftp_packet.dart'; +import 'package:test/test.dart'; + +void main() { + group('SFTP packet roundtrips', () { + final attrs = SftpFileAttrs( + size: 123, + userID: 1000, + groupID: 1000, + mode: const SftpFileMode.value(0x8000 | 0x1A4), + accessTime: 11, + modifyTime: 22, + extended: {'k': 'v'}, + ); + + test('init/version packets keep extensions', () { + final init = SftpInitPacket(3, {'posix-rename@openssh.com': '1'}); + final decodedInit = SftpInitPacket.decode(init.encode()); + expect(decodedInit.version, 3); + expect(decodedInit.extensions['posix-rename@openssh.com'], '1'); + + final version = SftpVersionPacket(3, {'fstatvfs@openssh.com': '2'}); + final decodedVersion = SftpVersionPacket.decode(version.encode()); + expect(decodedVersion.version, 3); + expect(decodedVersion.extensions['fstatvfs@openssh.com'], '2'); + }); + + test('request packets decode expected fields', () { + final open = SftpOpenPacket(1, '/tmp/a', 0x12, attrs); + final openDecoded = SftpOpenPacket.decode(open.encode()); + expect(openDecoded.requestId, 1); + expect(openDecoded.path, '/tmp/a'); + expect(openDecoded.flags, 0x12); + expect(openDecoded.attrs.size, 123); + + final read = SftpReadPacket( + requestId: 2, + handle: Uint8List.fromList([1, 2]), + offset: 42, + length: 9, + ); + final readDecoded = SftpReadPacket.decode(read.encode()); + expect(readDecoded.requestId, 2); + expect(readDecoded.handle, Uint8List.fromList([1, 2])); + expect(readDecoded.offset, 42); + expect(readDecoded.length, 9); + + final write = SftpWritePacket( + requestId: 3, + handle: Uint8List.fromList([3]), + offset: 7, + data: Uint8List.fromList([8, 9]), + ); + final writeDecoded = SftpWritePacket.decode(write.encode()); + expect(writeDecoded.requestId, 3); + expect(writeDecoded.handle, Uint8List.fromList([3])); + expect(writeDecoded.offset, 7); + expect(writeDecoded.data, Uint8List.fromList([8, 9])); + + final rename = SftpRenamePacket(4, '/a', '/b'); + final renameDecoded = SftpRenamePacket.decode(rename.encode()); + expect(renameDecoded.requestId, 4); + expect(renameDecoded.oldPath, '/a'); + expect(renameDecoded.newPath, '/b'); + }); + + test('response packets decode expected fields', () { + final status = SftpStatusPacket( + requestId: 7, + code: 5, + message: 'err', + language: 'en', + ); + final statusDecoded = SftpStatusPacket.decode(status.encode()); + expect(statusDecoded.requestId, 7); + expect(statusDecoded.code, 5); + expect(statusDecoded.message, 'err'); + expect(statusDecoded.language, 'en'); + + final data = SftpDataPacket(8, Uint8List.fromList([1, 2, 3])); + final dataDecoded = SftpDataPacket.decode(data.encode()); + expect(dataDecoded.requestId, 8); + expect(dataDecoded.data, Uint8List.fromList([1, 2, 3])); + + final name = SftpName( + filename: 'f.txt', + longname: '-rw-r--r-- f.txt', + attr: attrs, + ); + final names = SftpNamePacket(9, [name]); + final namesDecoded = SftpNamePacket.decode(names.encode()); + expect(namesDecoded.requestId, 9); + expect(namesDecoded.names.length, 1); + expect(namesDecoded.names.single.filename, 'f.txt'); + expect(namesDecoded.names.single.longname, '-rw-r--r-- f.txt'); + expect(namesDecoded.names.single.attr.size, 123); + + final attrsPacket = SftpAttrsPacket(10, attrs); + final attrsDecoded = SftpAttrsPacket.decode(attrsPacket.encode()); + expect(attrsDecoded.requestId, 10); + expect(attrsDecoded.attrs.size, 123); + expect(attrsDecoded.attrs.extended?['k'], 'v'); + }); + + test('extended packets preserve raw payload', () { + final payload = Uint8List.fromList([10, 20, 30, 40]); + final request = SftpExtendedPacket(11, payload); + final requestDecoded = SftpExtendedPacket.decode(request.encode()); + expect(requestDecoded.requestId, 11); + expect(requestDecoded.payload, payload); + + final reply = SftpExtendedReplyPacket(12, payload); + final replyDecoded = SftpExtendedReplyPacket.decode(reply.encode()); + expect(replyDecoded.requestId, 12); + expect(replyDecoded.payload, payload); + }); + }); +} diff --git a/test/src/sftp/sftp_stream_io_test.dart b/test/src/sftp/sftp_stream_io_test.dart index 5693fea..84b149f 100644 --- a/test/src/sftp/sftp_stream_io_test.dart +++ b/test/src/sftp/sftp_stream_io_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library sftp_stream_io_test; + import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/socket/ssh_socket_io_test.dart b/test/src/socket/ssh_socket_io_test.dart index 626f678..9ae9fe5 100644 --- a/test/src/socket/ssh_socket_io_test.dart +++ b/test/src/socket/ssh_socket_io_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library ssh_socket_io_test; + import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/ssh_client_test.dart b/test/src/ssh_client_test.dart index b1ece2d..8e2a0c2 100644 --- a/test/src/ssh_client_test.dart +++ b/test/src/ssh_client_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library ssh_client_test; + import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; From bf204c052976474fdedb62d872560fe2b5ff26fd Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:36:27 +0100 Subject: [PATCH 53/56] chore: update CHANGELOG.md with recent fixes for HTTP response parsing and SFTP packet consistency --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bc8edc..4ac65c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - Propagated the underlying exception in `SSHAuthAbortError` through `reason` for better diagnostics [#133]. Thanks [@james-thorpe] and [@vicajilau]. - Accepted `SSH-1.99-*` server banners as SSH-2 compatible during version exchange and added regression tests [#132]. Thanks [@james-thorpe] and [@vicajilau]. - Added SSH agent forwarding support (`auth-agent-req@openssh.com`) with in-memory agent handling and RSA sign-request flag support [#139]. Thanks [@Wackymax] and [@vicajilau]. +- Normalized HTTP response line parsing in `SSHHttpClientResponse` to handle CRLF endings consistently and avoid trailing line-ending artifacts in parsed status/header fields [#145]. Thanks [@vicajilau]. +- Fixed SFTP packet encoding/decoding consistency: `SftpInitPacket.decode` now parses extension pairs correctly and `SftpExtendedReplyPacket.encode` now preserves raw payload bytes [#145]. Thanks [@vicajilau]. ## [2.14.0] - 2026-03-19 - Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks [@shihuili1218]. @@ -185,6 +187,7 @@ [#141]: https://github.com/TerminalStudio/dartssh2/pull/141 [#140]: https://github.com/TerminalStudio/dartssh2/pull/140 +[#145]: https://github.com/TerminalStudio/dartssh2/pull/145 [#139]: https://github.com/TerminalStudio/dartssh2/pull/139 [#132]: https://github.com/TerminalStudio/dartssh2/pull/132 [#133]: https://github.com/TerminalStudio/dartssh2/pull/133 From 4750a0f012648583f22cbb55135f98f860a18438 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:53:24 +0100 Subject: [PATCH 54/56] chore: update actions/checkout and codecov-action versions in CI workflow --- .github/workflows/dart.yml | 4 ++-- codecov.yml | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 codecov.yml diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 9f32d1a..e89c8ef 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -18,7 +18,7 @@ jobs: sdk: [stable] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dart-lang/setup-dart@v1 with: sdk: ${{ matrix.sdk }} @@ -51,7 +51,7 @@ jobs: run: dart test --tags=integration - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: fail_ci_if_error: true # optional (default = false) files: ./coverage/lcov.info # optional diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..63bd216 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,19 @@ +coverage: + status: + project: + default: + target: auto + threshold: 3% + flags: + - unittests + patch: + default: + target: auto + threshold: 3% + flags: + - unittests + +comment: + layout: "reach,diff,flags,files" + behavior: default + require_changes: false From 1ce07af783253f23e4020ea886d94e8739545446 Mon Sep 17 00:00:00 2001 From: GT610 Date: Mon, 30 Mar 2026 18:11:47 +0800 Subject: [PATCH 55/56] fix: Fixed library declarations and error handling in multiple test files Fixed redundant library declarations in test files and improved error handling logic: 1. Removed redundant library declarations from test files 2. Added a toString method to SSHAuthAbortError 3. Added a failAll method to AsyncQueue to handle pending waits 4. Improved HTTP header validation and RSA signature verification 5. Updated the contributor format in CHANGELOG.md 6. Enhanced the connection closure logic in SSHClient 7. Added validation logic for the ident parameter --- CHANGELOG.md | 6 +- codecov.yml | 6 +- lib/src/http/http_client.dart | 4 ++ lib/src/ssh_agent.dart | 7 +- lib/src/ssh_channel.dart | 5 ++ lib/src/ssh_client.dart | 91 +++++++++++++++---------- lib/src/ssh_errors.dart | 8 +++ lib/src/utils/async_queue.dart | 10 +++ test/src/channel/ssh_channel_test.dart | 2 +- test/src/sftp/sftp_client_test.dart | 2 +- test/src/sftp/sftp_name_test.dart | 3 +- test/src/sftp/sftp_stream_io_test.dart | 2 +- test/src/socket/ssh_socket_io_test.dart | 2 +- test/src/ssh_client_test.dart | 2 +- 14 files changed, 101 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abd2cad..8d6bd2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## [2.15.0] - yyyy-mm-dd +## [2.15.0] - 2026-03-30 - Updated `pointycastle` dependency to `^4.0.0` [#131]. Thanks [@vicajilau]. - Added foundational X11 forwarding support with session x11-req API, incoming x11 channel handling, and protocol tests [#1]. Thanks [@vicajilau]. - Exposed SSH ident configuration from `SSHClient` [#135]. Thanks [@Remulic] and [@vicajilau]. @@ -9,8 +9,8 @@ - Fixed SFTP packet encoding/decoding consistency: `SftpInitPacket.decode` now parses extension pairs correctly and `SftpExtendedReplyPacket.encode` now preserves raw payload bytes [#145]. Thanks [@vicajilau]. ## [2.14.0] - 2026-03-19 -- Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks @shihuili1218. -- Adds a new forwardLocalUnix() function, which is an equivalent of ssh -L localPort:remoteSocketPath [#140]. Thanks @isegal. +- Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks [@shihuili1218]. +- Adds a new forwardLocalUnix() function, which is an equivalent of ssh -L localPort:remoteSocketPath [#140]. Thanks [@isegal]. ## [2.13.0] - 2025-06-22 - docs: Update NoPorts naming [#115]. [@XavierChanth]. diff --git a/codecov.yml b/codecov.yml index 63bd216..b12366a 100644 --- a/codecov.yml +++ b/codecov.yml @@ -3,17 +3,17 @@ coverage: project: default: target: auto - threshold: 3% + threshold: 5% flags: - unittests patch: default: target: auto - threshold: 3% + threshold: 2% flags: - unittests comment: layout: "reach,diff,flags,files" behavior: default - require_changes: false + require_changes: true diff --git a/lib/src/http/http_client.dart b/lib/src/http/http_client.dart index 6a8fef3..dc0c103 100644 --- a/lib/src/http/http_client.dart +++ b/lib/src/http/http_client.dart @@ -499,6 +499,10 @@ class SSHHttpClientResponse { return; } final separator = normalizedLine.indexOf(':'); + if (separator <= 0) { + throw FormatException( + 'Invalid header line: "$normalizedLine" - no colon separator found'); + } final name = normalizedLine.substring(0, separator).toLowerCase().trim(); final value = normalizedLine.substring(separator + 1).trim(); diff --git a/lib/src/ssh_agent.dart b/lib/src/ssh_agent.dart index f894e74..f7a67c8 100644 --- a/lib/src/ssh_agent.dart +++ b/lib/src/ssh_agent.dart @@ -95,7 +95,12 @@ 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) { + return signature; + } + throw StateError( + 'RSA signing requested but identity produced non-RSA signature: ${signature.runtimeType}'); } final signer = _rsaSignerFor(signatureType); diff --git a/lib/src/ssh_channel.dart b/lib/src/ssh_channel.dart index dd968e7..3254cb4 100644 --- a/lib/src/ssh_channel.dart +++ b/lib/src/ssh_channel.dart @@ -70,6 +70,11 @@ class SSHChannelController { /// An [AsyncQueue] of pending request replies from the remote side. final _requestReplyQueue = AsyncQueue(); + /// Fails all pending request reply waiters. + void failPendingRequestReplies(Object error, [StackTrace? stackTrace]) { + _requestReplyQueue.failAll(error, stackTrace ?? StackTrace.current); + } + /// true if we have sent an EOF message to the remote side. var _hasSentEOF = false; diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index 1e36cf0..b297abe 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -207,37 +207,38 @@ class SSHClient { /// true if the connection is closed normally or due to an error. bool get isClosed => _transport.isClosed; - SSHClient(this.socket, - {required this.username, - this.printDebug, - this.printTrace, - this.algorithms = const SSHAlgorithms(), - this.onVerifyHostKey, - this.identities, - this.hostbasedIdentities, - this.hostName, - this.userNameOnClientHost, - this.onPasswordRequest, - this.onChangePasswordRequest, - this.onUserInfoRequest, - this.onUserauthBanner, - this.onAuthenticated, - this.keepAliveInterval = const Duration(seconds: 10), - - /// Authentication timeout period. RFC 4252 recommends 10 minutes. - this.authTimeout = defaultAuthTimeout, - - /// Handshake timeout period. Defaults to 30s. - this.handshakeTimeout = defaultHandshakeTimeout, - - /// Maximum authentication attempts. RFC 4252 recommends 20 attempts. - this.maxAuthAttempts = defaultMaxAuthAttempts, - this.onHostKeys, - this.disableHostkeyVerification = false, - this.onX11Forward, - this.agentHandler, - String ident = 'DartSSH_2.0', - }) : _ident = _validateIdent(ident) { + SSHClient( + this.socket, { + required this.username, + this.printDebug, + this.printTrace, + this.algorithms = const SSHAlgorithms(), + this.onVerifyHostKey, + this.identities, + this.hostbasedIdentities, + this.hostName, + this.userNameOnClientHost, + this.onPasswordRequest, + this.onChangePasswordRequest, + this.onUserInfoRequest, + this.onUserauthBanner, + this.onAuthenticated, + this.keepAliveInterval = const Duration(seconds: 10), + + /// Authentication timeout period. RFC 4252 recommends 10 minutes. + this.authTimeout = defaultAuthTimeout, + + /// Handshake timeout period. Defaults to 30s. + this.handshakeTimeout = defaultHandshakeTimeout, + + /// Maximum authentication attempts. RFC 4252 recommends 20 attempts. + this.maxAuthAttempts = defaultMaxAuthAttempts, + this.onHostKeys, + this.disableHostkeyVerification = false, + this.onX11Forward, + this.agentHandler, + String ident = 'DartSSH_2.0', + }) : _ident = _validateIdent(ident) { _transport = SSHTransport( socket, isServer: false, @@ -287,6 +288,14 @@ class SSHClient { ); } + if (ident.startsWith('SSH-')) { + throw ArgumentError.value( + ident, + 'ident', + 'must not include SSH- prefix', + ); + } + if (ident.contains('\r') || ident.contains('\n')) { throw ArgumentError.value( ident, @@ -689,6 +698,16 @@ class SSHClient { } _channelOpenReplyWaiters.clear(); + // Fail any pending global request replies (e.g. ping, forwardRemote). + _globalRequestReplyQueue.failAll(SSHStateError( + 'Connection closed while waiting for global request reply')); + + // Fail pending request replies for each channel. + for (final controller in _channels.values) { + controller.failPendingRequestReplies(SSHStateError( + 'Connection closed while waiting for channel request reply')); + } + try { _closeChannels(); } catch (e) { @@ -1111,11 +1130,11 @@ class SSHClient { void _handleChannelConfirmation(Uint8List payload) { final message = SSH_Message_Channel_Confirmation.decode(payload); printTrace?.call('<- $socket: $message'); - // Register the channel synchronously BEFORE completing the future. - // CHANNEL_DATA for this channel may arrive in the same TCP segment as - // the CONFIRMATION. If we defer registration to the async continuation - // of _waitChannelOpen, that data hits _handleChannelData while - // _channels[id] is still null and is silently dropped. + if (_channels.containsKey(message.recipientChannel)) { + printDebug?.call( + '_handleChannelConfirmation: channel ${message.recipientChannel} already closed, discarding'); + return; + } _acceptChannel( localChannelId: message.recipientChannel, remoteChannelId: message.senderChannel, diff --git a/lib/src/ssh_errors.dart b/lib/src/ssh_errors.dart index fd985ce..982efb1 100644 --- a/lib/src/ssh_errors.dart +++ b/lib/src/ssh_errors.dart @@ -53,6 +53,14 @@ class SSHAuthAbortError with SSHMessageError implements SSHAuthError { final SSHError? reason; SSHAuthAbortError(this.message, [this.reason]); + + @override + String toString() { + if (reason != null) { + return 'SSHAuthAbortError($message, reason: $reason)'; + } + return 'SSHAuthAbortError($message)'; + } } /// Errors that happen when the library receives an malformed packet. diff --git a/lib/src/utils/async_queue.dart b/lib/src/utils/async_queue.dart index cc23550..f2041e9 100644 --- a/lib/src/utils/async_queue.dart +++ b/lib/src/utils/async_queue.dart @@ -33,4 +33,14 @@ class AsyncQueue { _data.add(value); } } + + /// Fails all pending waiters with the given error. + void failAll(Object error, [StackTrace? stackTrace]) { + for (final completer in _completers) { + if (!completer.isCompleted) { + completer.completeError(error, stackTrace ?? StackTrace.current); + } + } + _completers.clear(); + } } diff --git a/test/src/channel/ssh_channel_test.dart b/test/src/channel/ssh_channel_test.dart index 17183e1..9f6420a 100644 --- a/test/src/channel/ssh_channel_test.dart +++ b/test/src/channel/ssh_channel_test.dart @@ -1,5 +1,5 @@ @Tags(['integration']) -library ssh_channel_test; +library; import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/sftp/sftp_client_test.dart b/test/src/sftp/sftp_client_test.dart index a9f84e0..ffa1e83 100644 --- a/test/src/sftp/sftp_client_test.dart +++ b/test/src/sftp/sftp_client_test.dart @@ -1,5 +1,5 @@ @Tags(['integration']) -library sftp_client_test; +library; import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/sftp/sftp_name_test.dart b/test/src/sftp/sftp_name_test.dart index 26754c6..1264867 100644 --- a/test/src/sftp/sftp_name_test.dart +++ b/test/src/sftp/sftp_name_test.dart @@ -10,7 +10,8 @@ void main() { longname: '-rw-r--r-- 1 user group 10 report.txt', attr: SftpFileAttrs( size: 10, - mode: const SftpFileMode.value((1 << 15) + 0x1A4), + mode: const SftpFileMode.value( + (1 << 15) + 0x1A4), // 0x1A4 = 0644 rw-r--r-- ), ); diff --git a/test/src/sftp/sftp_stream_io_test.dart b/test/src/sftp/sftp_stream_io_test.dart index 84b149f..4e6f77c 100644 --- a/test/src/sftp/sftp_stream_io_test.dart +++ b/test/src/sftp/sftp_stream_io_test.dart @@ -1,5 +1,5 @@ @Tags(['integration']) -library sftp_stream_io_test; +library; import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/socket/ssh_socket_io_test.dart b/test/src/socket/ssh_socket_io_test.dart index 9ae9fe5..f08b6ae 100644 --- a/test/src/socket/ssh_socket_io_test.dart +++ b/test/src/socket/ssh_socket_io_test.dart @@ -1,5 +1,5 @@ @Tags(['integration']) -library ssh_socket_io_test; +library; import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/ssh_client_test.dart b/test/src/ssh_client_test.dart index 9fe5ee8..084325b 100644 --- a/test/src/ssh_client_test.dart +++ b/test/src/ssh_client_test.dart @@ -1,5 +1,5 @@ @Tags(['integration']) -library ssh_client_test; +library; import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; From 9646f04f7fb30e8a895f8ec5433a64d2647df5e4 Mon Sep 17 00:00:00 2001 From: GT610 Date: Mon, 30 Mar 2026 18:49:32 +0800 Subject: [PATCH 56/56] fix(ssh): Added frame size limit and fixed error handling when the connection is closed Added a maximum frame size limit for SSHAgentChannel to prevent memory exhaustion Fixed an issue where the authentication timeout timer was not reset when the connection was closed Improved error handling to ensure error messages are properly passed when the connection is closed --- lib/src/ssh_agent.dart | 9 +++++++++ lib/src/ssh_client.dart | 33 +++++++++++++++++++++++---------- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/lib/src/ssh_agent.dart b/lib/src/ssh_agent.dart index f7a67c8..f5f360b 100644 --- a/lib/src/ssh_agent.dart +++ b/lib/src/ssh_agent.dart @@ -159,6 +159,8 @@ class SSHKeyPairAgent implements SSHAgentHandler { } class SSHAgentChannel { + static const maxFrameSize = 256 * 1024; + SSHAgentChannel(this._channel, this._handler, {this.printDebug}) { _subscription = _channel.stream.listen( _handleData, @@ -193,6 +195,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_client.dart b/lib/src/ssh_client.dart index b297abe..54e1dc0 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -672,6 +672,8 @@ class SSHClient { printDebug?.call('SSHClient._onTransportClosed'); _handshakeTimeoutTimer?.cancel(); _handshakeTimeoutTimer = null; + _authTimeoutTimer?.cancel(); + _authTimeoutTimer = null; if (!_authenticated.isCompleted) { final currentMethod = _currentAuthMethod != null ? "Current method: ${_currentAuthMethod!.name}" @@ -682,7 +684,8 @@ class SSHClient { _authenticated.completeError( SSHAuthAbortError( - 'Connection closed before authentication. $currentMethod. $attempts'), + 'Connection closed before authentication. $currentMethod. $attempts', + error), ); } _keepAlive?.stop(); @@ -691,21 +694,22 @@ class SSHClient { // forwardLocalUnix) don't hang forever when the connection drops. for (final entry in _channelOpenReplyWaiters.entries) { if (!entry.value.isCompleted) { - entry.value.completeError( - SSHStateError('Connection closed while waiting for channel open'), - ); + entry.value.completeError(error ?? + SSHStateError('Connection closed while waiting for channel open')); } } _channelOpenReplyWaiters.clear(); // Fail any pending global request replies (e.g. ping, forwardRemote). - _globalRequestReplyQueue.failAll(SSHStateError( - 'Connection closed while waiting for global request reply')); + _globalRequestReplyQueue.failAll(error ?? + SSHStateError( + 'Connection closed while waiting for global request reply')); // Fail pending request replies for each channel. for (final controller in _channels.values) { - controller.failPendingRequestReplies(SSHStateError( - 'Connection closed while waiting for channel request reply')); + controller.failPendingRequestReplies(error ?? + SSHStateError( + 'Connection closed while waiting for channel request reply')); } try { @@ -1130,6 +1134,11 @@ class SSHClient { void _handleChannelConfirmation(Uint8List payload) { final message = SSH_Message_Channel_Confirmation.decode(payload); printTrace?.call('<- $socket: $message'); + if (!_channelOpenReplyWaiters.containsKey(message.recipientChannel)) { + printDebug?.call( + '_handleChannelConfirmation: no pending open for local channel ${message.recipientChannel}, discarding'); + return; + } if (_channels.containsKey(message.recipientChannel)) { printDebug?.call( '_handleChannelConfirmation: channel ${message.recipientChannel} already closed, discarding'); @@ -1533,8 +1542,12 @@ class SSHClient { throw SSHChannelOpenError(message.reasonCode, message.description); } - // Channel was already registered synchronously in _handleChannelConfirmation. - return _channels[localChannelId]!; + final controller = _channels[localChannelId]; + if (controller == null) { + throw SSHStateError( + 'Channel $localChannelId was closed before channel-open completed'); + } + return controller; } SSHChannelController _acceptChannel({
ServerBox NoPorts DartShell Naviterm
- ServerBox interface - ServerBox interface + ServerBox interface displaying connection management options + ServerBox user interface for server control and monitoring - NoPorts demo + NoPorts demo showcasing SSH connectivity without open ports - dartShell interface + dartShell displaying terminal and session information for SSH operations NaviTerm: Professional SSH Terminal for iOS From b2e1264ca3744a79bacd007fb80cc58fef45c06d Mon Sep 17 00:00:00 2001 From: Trivix Date: Fri, 2 Jan 2026 00:01:06 +0800 Subject: [PATCH 26/56] Update image alt text for clarity --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 83e2809..85bbaa3 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as e ServerBox interface displaying connection management options ServerBox user interface for server control and monitoring NoPorts demo showcasing SSH connectivity without open ports @@ -72,7 +72,7 @@ SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as e - NaviTerm: Professional SSH Terminal for iOS + Your all-in-one SSH terminal, SFTP client, and port forwarding tool, built from the ground up for macOS, iPhone, and iPad.