From 51ed7a2b37a8070b127723e666ef2ac3433039d5 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:41:23 +0100 Subject: [PATCH 01/24] fix(web): make SSHSocket import wasm-compatible --- lib/src/socket/ssh_socket.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/socket/ssh_socket.dart b/lib/src/socket/ssh_socket.dart index 725a7ce..13a1d07 100644 --- a/lib/src/socket/ssh_socket.dart +++ b/lib/src/socket/ssh_socket.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:typed_data'; -import 'package:dartssh2/src/socket/ssh_socket_io.dart' - if (dart.library.js) 'package:dartssh2/src/socket/ssh_socket_js.dart'; +import 'package:dartssh2/src/socket/ssh_socket_js.dart' + if (dart.library.io) 'package:dartssh2/src/socket/ssh_socket_io.dart'; abstract class SSHSocket { /// Connects using the platform native socket transport. From 1407474e19787069ebc1e6712ccbe8300e246b90 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:44:14 +0100 Subject: [PATCH 02/24] chore(release): bump version to 2.17.0 and update CHANGELOG for Web/WASM compatibility improvements --- CHANGELOG.md | 5 +++++ pubspec.yaml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72fd96b..e677ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [2.17.0] - yyyy-mm-dd +- Improved Web/WASM compatibility by updating `SSHSocket` conditional imports so web runtimes consistently use the web socket shim and avoid incorrect native socket selection [#88]. Thanks [@vicajilau]. + ## [2.16.0] - 2026-03-24 - **BREAKING**: Changed `SSHChannelController.sendEnv()` from `void` to `Future` to properly await environment variable setup responses and avoid race conditions with PTY requests [#102]. Thanks [@itzhoujun] and [@vicajilau]. - Clarified shell stdio wiring for CLI-only usage and guarded `example/shell.dart` against missing local terminal handles (for example GUI-launched Windows `.exe`) [#121]. Thanks [@bradmartin333] and [@vicajilau]. @@ -196,12 +199,14 @@ [#141]: https://github.com/TerminalStudio/dartssh2/pull/141 [#140]: https://github.com/TerminalStudio/dartssh2/pull/140 [#145]: https://github.com/TerminalStudio/dartssh2/pull/145 +[#153]: https://github.com/TerminalStudio/dartssh2/pull/153 [#102]: https://github.com/TerminalStudio/dartssh2/issues/102 [#99]: https://github.com/TerminalStudio/dartssh2/issues/99 [#109]: https://github.com/TerminalStudio/dartssh2/issues/109 [#121]: https://github.com/TerminalStudio/dartssh2/issues/121 [#124]: https://github.com/TerminalStudio/dartssh2/issues/124 [#95]: https://github.com/TerminalStudio/dartssh2/issues/95 +[#88]: https://github.com/TerminalStudio/dartssh2/issues/88 [#139]: https://github.com/TerminalStudio/dartssh2/pull/139 [#132]: https://github.com/TerminalStudio/dartssh2/pull/132 [#133]: https://github.com/TerminalStudio/dartssh2/pull/133 diff --git a/pubspec.yaml b/pubspec.yaml index e3a30ab..c9fde59 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.16.0 +version: 2.17.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 fcf2de70dec8385039a9ec19c6cea88f90bba396 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:48:52 +0100 Subject: [PATCH 03/24] fix(changelog): update issue reference for Web/WASM compatibility improvements --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e677ea0..003f1b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ ## [2.17.0] - yyyy-mm-dd -- Improved Web/WASM compatibility by updating `SSHSocket` conditional imports so web runtimes consistently use the web socket shim and avoid incorrect native socket selection [#88]. Thanks [@vicajilau]. +- Improved Web/WASM compatibility by updating `SSHSocket` conditional imports so web runtimes consistently use the web socket shim and avoid incorrect native socket selection [#153]. Thanks [@vicajilau]. ## [2.16.0] - 2026-03-24 - **BREAKING**: Changed `SSHChannelController.sendEnv()` from `void` to `Future` to properly await environment variable setup responses and avoid race conditions with PTY requests [#102]. Thanks [@itzhoujun] and [@vicajilau]. @@ -206,7 +206,6 @@ [#121]: https://github.com/TerminalStudio/dartssh2/issues/121 [#124]: https://github.com/TerminalStudio/dartssh2/issues/124 [#95]: https://github.com/TerminalStudio/dartssh2/issues/95 -[#88]: https://github.com/TerminalStudio/dartssh2/issues/88 [#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 c37417ddcdd43f1436db2f5d2edf5e316631c74d Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:50:17 +0100 Subject: [PATCH 04/24] fix(changelog): correct issue reference for Web/WASM compatibility improvements --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 003f1b2..e677ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ ## [2.17.0] - yyyy-mm-dd -- Improved Web/WASM compatibility by updating `SSHSocket` conditional imports so web runtimes consistently use the web socket shim and avoid incorrect native socket selection [#153]. Thanks [@vicajilau]. +- Improved Web/WASM compatibility by updating `SSHSocket` conditional imports so web runtimes consistently use the web socket shim and avoid incorrect native socket selection [#88]. Thanks [@vicajilau]. ## [2.16.0] - 2026-03-24 - **BREAKING**: Changed `SSHChannelController.sendEnv()` from `void` to `Future` to properly await environment variable setup responses and avoid race conditions with PTY requests [#102]. Thanks [@itzhoujun] and [@vicajilau]. @@ -206,6 +206,7 @@ [#121]: https://github.com/TerminalStudio/dartssh2/issues/121 [#124]: https://github.com/TerminalStudio/dartssh2/issues/124 [#95]: https://github.com/TerminalStudio/dartssh2/issues/95 +[#88]: https://github.com/TerminalStudio/dartssh2/issues/88 [#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 66384b6448f5f408fbe968a52eda63b331626d5e Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:16:00 +0100 Subject: [PATCH 05/24] feat: add dynamic SOCKS5 forwarding API --- CHANGELOG.md | 1 + README.md | 36 ++- example/forward_dynamic.dart | 48 ++++ lib/src/dynamic_forward.dart | 24 ++ lib/src/dynamic_forward_io.dart | 377 ++++++++++++++++++++++++++++++ lib/src/dynamic_forward_stub.dart | 18 ++ lib/src/ssh_client.dart | 25 ++ lib/src/ssh_forward.dart | 38 +++ 8 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 example/forward_dynamic.dart create mode 100644 lib/src/dynamic_forward.dart create mode 100644 lib/src/dynamic_forward_io.dart create mode 100644 lib/src/dynamic_forward_stub.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e677ea0..98d5529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [2.17.0] - yyyy-mm-dd - Improved Web/WASM compatibility by updating `SSHSocket` conditional imports so web runtimes consistently use the web socket shim and avoid incorrect native socket selection [#88]. Thanks [@vicajilau]. +- Added local dynamic forwarding (`SSHClient.forwardDynamic`) with SOCKS5 `NO AUTH` + `CONNECT`, including configurable handshake/connect timeouts and connection limits. ## [2.16.0] - 2026-03-24 - **BREAKING**: Changed `SSHChannelController.sendEnv()` from `void` to `Future` to properly await environment variable setup responses and avoid race conditions with PTY requests [#102]. Thanks [@itzhoujun] and [@vicajilau]. diff --git a/README.md b/README.md index 6018799..53ea6e2 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as e - **Pure Dart**: Working with both Dart VM and Flutter. - **SSH Session**: Executing commands, spawning shells, setting environment variables, pseudo terminals, etc. - **Authentication**: Supports password, private key and interactive authentication method. -- **Forwarding**: Supports local forwarding and remote forwarding. +- **Forwarding**: Supports local forwarding, remote forwarding, and dynamic forwarding (SOCKS5 CONNECT). - **SFTP**: Supports all operations defined in [SFTPv3 protocol](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02) including upload, download, list, link, remove, rename, etc. ## 🧬 Built with dartssh2 @@ -296,6 +296,40 @@ void main() async { } ``` +### Start a local SOCKS5 proxy through SSH (`ssh -D` style) + +```dart +void main() async { + final dynamicForward = await client.forwardDynamic( + bindHost: '127.0.0.1', + bindPort: 1080, + options: const SSHDynamicForwardOptions( + handshakeTimeout: Duration(seconds: 10), + connectTimeout: Duration(seconds: 15), + maxConnections: 128, + ), + filter: (host, port) { + // Optional allow/deny policy. + return true; + }, + ); + + print('SOCKS5 proxy at ${dynamicForward.host}:${dynamicForward.port}'); +} +``` + +This currently supports SOCKS5 `NO AUTH` + `CONNECT`. +It requires `dart:io` and is not available on web runtimes. + +Quick verification from your terminal: + +```sh +curl --proxy socks5h://127.0.0.1:1080 https://ifconfig.me +``` + +If the proxy is working, this command returns the public egress IP seen through +the SSH tunnel. + ### Authenticate with public keys ```dart diff --git a/example/forward_dynamic.dart b/example/forward_dynamic.dart new file mode 100644 index 0000000..41bc860 --- /dev/null +++ b/example/forward_dynamic.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:dartssh2/dartssh2.dart'; + +Future main() async { + final host = Platform.environment['SSH_HOST'] ?? 'localhost'; + final port = int.tryParse(Platform.environment['SSH_PORT'] ?? '') ?? 22; + final username = Platform.environment['SSH_USERNAME'] ?? 'root'; + final password = Platform.environment['SSH_PASSWORD']; + + final socket = await SSHSocket.connect(host, port); + + final client = SSHClient( + socket, + username: username, + onPasswordRequest: () => password, + ); + + await client.authenticated; + + final dynamicForward = await client.forwardDynamic( + bindHost: '127.0.0.1', + bindPort: 1080, + options: const SSHDynamicForwardOptions( + handshakeTimeout: Duration(seconds: 10), + connectTimeout: Duration(seconds: 15), + maxConnections: 64, + ), + filter: (targetHost, targetPort) { + // Allow only web ports in this sample. + return targetPort == 80 || targetPort == 443; + }, + ); + + print( + 'SOCKS5 proxy ready on ${dynamicForward.host}:${dynamicForward.port}.', + ); + print('Press Ctrl+C to stop.'); + + ProcessSignal.sigint.watch().listen((_) async { + await dynamicForward.close(); + client.close(); + await client.done; + exit(0); + }); + + await Future.delayed(const Duration(days: 365)); +} diff --git a/lib/src/dynamic_forward.dart b/lib/src/dynamic_forward.dart new file mode 100644 index 0000000..f9d3e7d --- /dev/null +++ b/lib/src/dynamic_forward.dart @@ -0,0 +1,24 @@ +import 'package:dartssh2/src/dynamic_forward_stub.dart' + if (dart.library.io) 'package:dartssh2/src/dynamic_forward_io.dart' as impl; +import 'package:dartssh2/src/ssh_forward.dart'; + +typedef SSHDynamicDial = Future Function( + String host, + int port, +); + +Future startDynamicForward({ + required String bindHost, + required int? bindPort, + required SSHDynamicForwardOptions options, + SSHDynamicConnectionFilter? filter, + required SSHDynamicDial dial, +}) { + return impl.startDynamicForward( + bindHost: bindHost, + bindPort: bindPort, + options: options, + filter: filter, + dial: dial, + ); +} diff --git a/lib/src/dynamic_forward_io.dart b/lib/src/dynamic_forward_io.dart new file mode 100644 index 0000000..191b334 --- /dev/null +++ b/lib/src/dynamic_forward_io.dart @@ -0,0 +1,377 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dartssh2/src/ssh_forward.dart'; + +typedef SSHDynamicDial = Future Function( + String host, + int port, +); + +Future startDynamicForward({ + required String bindHost, + required int? bindPort, + required SSHDynamicForwardOptions options, + SSHDynamicConnectionFilter? filter, + required SSHDynamicDial dial, +}) async { + final server = await ServerSocket.bind(bindHost, bindPort ?? 0); + return _SSHDynamicForwardImpl( + server, + options: options, + filter: filter, + dial: dial, + ); +} + +class _SSHDynamicForwardImpl implements SSHDynamicForward { + _SSHDynamicForwardImpl( + this._server, { + required this.options, + required this.filter, + required this.dial, + }) { + _serverSub = _server.listen(_handleClient); + } + + final ServerSocket _server; + final SSHDynamicForwardOptions options; + final SSHDynamicConnectionFilter? filter; + final SSHDynamicDial dial; + late final StreamSubscription _serverSub; + final _connections = <_SocksConnection>{}; + bool _closed = false; + + @override + String get host => _server.address.host; + + @override + int get port => _server.port; + + @override + bool get isClosed => _closed; + + void _handleClient(Socket client) { + if (_closed) { + client.destroy(); + return; + } + + late final _SocksConnection connection; + connection = _SocksConnection( + client, + options: options, + filter: filter, + canOpenTunnel: () => _connections.length <= options.maxConnections, + dial: dial, + onClosed: () => _connections.remove(connection), + ); + + _connections.add(connection); + connection.start(); + } + + @override + Future close() async { + if (_closed) return; + _closed = true; + + await _serverSub.cancel(); + await _server.close(); + + final closes = + _connections.map((connection) => connection.close()).toList(); + await Future.wait(closes); + _connections.clear(); + } +} + +class _SocksConnection { + _SocksConnection( + this._client, { + required this.options, + required this.filter, + required this.canOpenTunnel, + required this.dial, + required this.onClosed, + }); + + static const _socksVersion = 0x05; + + final Socket _client; + final SSHDynamicForwardOptions options; + final SSHDynamicConnectionFilter? filter; + final bool Function() canOpenTunnel; + final SSHDynamicDial dial; + final void Function() onClosed; + + final _buffer = _ByteBuffer(); + + SSHForwardChannel? _remote; + StreamSubscription>? _clientSub; + StreamSubscription? _remoteSub; + Timer? _handshakeTimer; + bool _closed = false; + _SocksState _state = _SocksState.greeting; + + void start() { + _handshakeTimer = Timer(options.handshakeTimeout, () async { + _sendReply(_SocksReply.ttlExpired); + await close(); + }); + + _clientSub = _client.listen( + _onClientData, + onDone: close, + onError: (_, __) => close(), + cancelOnError: true, + ); + } + + Future close() async { + if (_closed) return; + _closed = true; + + await _clientSub?.cancel(); + await _remoteSub?.cancel(); + _handshakeTimer?.cancel(); + + _remote?.destroy(); + _client.destroy(); + + onClosed(); + } + + Future _onClientData(List chunk) async { + if (_closed) return; + + if (_state == _SocksState.streaming) { + _remote?.sink.add(chunk); + return; + } + + _buffer.add(chunk); + + try { + await _consumeHandshake(); + } catch (_) { + await close(); + } + } + + Future _consumeHandshake() async { + if (_state == _SocksState.greeting) { + final parsed = _parseGreeting(); + if (!parsed) return; + _state = _SocksState.request; + } + + if (_state == _SocksState.request) { + final target = _parseConnectRequest(); + if (target == null) return; + + if (filter != null && !filter!(target.host, target.port)) { + _sendReply(_SocksReply.connectionNotAllowed); + await close(); + return; + } + + if (!canOpenTunnel()) { + _sendReply(_SocksReply.connectionRefused); + await close(); + return; + } + + try { + _remote = await dial(target.host, target.port).timeout( + options.connectTimeout, + ); + } catch (_) { + _sendReply(_SocksReply.hostUnreachable); + await close(); + return; + } + + _remoteSub = _remote!.stream.listen( + _client.add, + onDone: close, + onError: (_, __) => close(), + cancelOnError: true, + ); + + _sendReply(_SocksReply.succeeded); + _handshakeTimer?.cancel(); + _handshakeTimer = null; + _state = _SocksState.streaming; + + final pending = _buffer.takeAll(); + if (pending.isNotEmpty) { + _remote!.sink.add(pending); + } + } + } + + bool _parseGreeting() { + if (_buffer.length < 2) return false; + + final version = _buffer.peek(0); + final methodsCount = _buffer.peek(1); + final totalLength = 2 + methodsCount; + + if (_buffer.length < totalLength) return false; + + final payload = _buffer.read(totalLength); + + if (version != _socksVersion) { + _sendMethodSelection(0xFF); + throw StateError('Unsupported SOCKS version'); + } + + final methods = payload.sublist(2); + if (methods.contains(0x00)) { + _sendMethodSelection(0x00); + } else { + _sendMethodSelection(0xFF); + throw StateError('No supported authentication method'); + } + + return true; + } + + _TargetAddress? _parseConnectRequest() { + if (_buffer.length < 4) return null; + + final version = _buffer.peek(0); + final command = _buffer.peek(1); + final atyp = _buffer.peek(3); + + if (version != _socksVersion) { + _sendReply(_SocksReply.generalFailure); + throw StateError('Unsupported SOCKS version'); + } + + if (command != 0x01) { + _sendReply(_SocksReply.commandNotSupported); + throw StateError('Unsupported SOCKS command'); + } + + int requiredLength; + if (atyp == 0x01) { + requiredLength = 10; + } else if (atyp == 0x03) { + if (_buffer.length < 5) return null; + requiredLength = 7 + _buffer.peek(4); + } else if (atyp == 0x04) { + requiredLength = 22; + } else { + _sendReply(_SocksReply.addressTypeNotSupported); + throw StateError('Unsupported SOCKS address type'); + } + + if (_buffer.length < requiredLength) return null; + + final request = _buffer.read(requiredLength); + final host = _decodeHost(request, atyp); + final portOffset = requiredLength - 2; + final port = (request[portOffset] << 8) | request[portOffset + 1]; + + return _TargetAddress(host, port); + } + + String _decodeHost(Uint8List request, int atyp) { + if (atyp == 0x01) { + return '${request[4]}.${request[5]}.${request[6]}.${request[7]}'; + } + + if (atyp == 0x03) { + final length = request[4]; + final bytes = request.sublist(5, 5 + length); + return utf8.decode(bytes); + } + + final raw = request.sublist(4, 20); + return InternetAddress.fromRawAddress(Uint8List.fromList(raw)).address; + } + + void _sendMethodSelection(int method) { + _client.add([_socksVersion, method]); + } + + void _sendReply(_SocksReply reply) { + _client.add([ + _socksVersion, + reply.code, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ]); + } +} + +class _ByteBuffer { + final _data = []; + int _offset = 0; + + int get length => _data.length - _offset; + + void add(List chunk) { + _data.addAll(chunk); + } + + int peek(int index) => _data[_offset + index]; + + Uint8List read(int count) { + final slice = Uint8List.fromList(_data.sublist(_offset, _offset + count)); + _offset += count; + + if (_offset >= _data.length) { + _data.clear(); + _offset = 0; + } else if (_offset > 1024 && _offset * 2 > _data.length) { + _data.removeRange(0, _offset); + _offset = 0; + } + + return slice; + } + + Uint8List takeAll() { + if (length == 0) return Uint8List(0); + return read(length); + } +} + +class _TargetAddress { + final String host; + final int port; + + const _TargetAddress(this.host, this.port); +} + +enum _SocksState { + greeting, + request, + streaming, +} + +enum _SocksReply { + succeeded(0x00), + generalFailure(0x01), + connectionNotAllowed(0x02), + connectionRefused(0x05), + ttlExpired(0x06), + hostUnreachable(0x04), + commandNotSupported(0x07), + addressTypeNotSupported(0x08); + + final int code; + + const _SocksReply(this.code); +} diff --git a/lib/src/dynamic_forward_stub.dart b/lib/src/dynamic_forward_stub.dart new file mode 100644 index 0000000..dc7e239 --- /dev/null +++ b/lib/src/dynamic_forward_stub.dart @@ -0,0 +1,18 @@ +import 'package:dartssh2/src/ssh_forward.dart'; + +typedef SSHDynamicDial = Future Function( + String host, + int port, +); + +Future startDynamicForward({ + required String bindHost, + required int? bindPort, + required SSHDynamicForwardOptions options, + SSHDynamicConnectionFilter? filter, + required SSHDynamicDial dial, +}) { + throw UnsupportedError( + 'Dynamic forwarding requires dart:io and is not supported on this platform.', + ); +} diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index c3bbc2c..a59d71a 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -4,6 +4,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/dynamic_forward.dart'; import 'package:dartssh2/src/ssh_algorithm.dart'; import 'package:dartssh2/src/ssh_agent.dart'; import 'package:dartssh2/src/ssh_channel.dart'; @@ -382,6 +383,30 @@ class SSHClient { return SSHForwardChannel(channelController.channel); } + /// Start a local SOCKS5 server that forwards outbound `CONNECT` requests + /// through this SSH connection. + /// + /// This is similar to `ssh -D`. Only SOCKS5 with `NO AUTH` and `CONNECT` + /// is supported. Use [filter] to optionally deny specific target + /// destinations. Use [options] to tune timeouts and connection limits. + /// + /// Not supported on platforms without `dart:io`. + Future forwardDynamic({ + String bindHost = '127.0.0.1', + int? bindPort, + SSHDynamicForwardOptions options = const SSHDynamicForwardOptions(), + SSHDynamicConnectionFilter? filter, + }) async { + await _authenticated.future; + return startDynamicForward( + bindHost: bindHost, + bindPort: bindPort, + options: options, + filter: filter, + dial: forwardLocal, + ); + } + /// Forward local connections to a remote Unix domain socket at [remoteSocketPath] on the /// remote side via a `direct-streamlocal@openssh.com` channel. /// diff --git a/lib/src/ssh_forward.dart b/lib/src/ssh_forward.dart index f2b462b..2a03f2c 100644 --- a/lib/src/ssh_forward.dart +++ b/lib/src/ssh_forward.dart @@ -4,6 +4,44 @@ import 'dart:typed_data'; import 'package:dartssh2/src/socket/ssh_socket.dart'; import 'package:dartssh2/src/ssh_channel.dart'; +/// Filters outbound targets requested through a dynamic forward (SOCKS proxy). +/// +/// Return `true` to allow connecting to `[host]:[port]`, `false` to deny. +typedef SSHDynamicConnectionFilter = bool Function(String host, int port); + +/// Configuration for [SSHClient.forwardDynamic]. +class SSHDynamicForwardOptions { + /// Maximum time allowed to complete the SOCKS5 handshake and target request. + final Duration handshakeTimeout; + + /// Maximum time allowed to establish the SSH forwarded connection to target. + final Duration connectTimeout; + + /// Maximum number of simultaneous SOCKS client connections. + final int maxConnections; + + const SSHDynamicForwardOptions({ + this.handshakeTimeout = const Duration(seconds: 10), + this.connectTimeout = const Duration(seconds: 15), + this.maxConnections = 128, + }) : assert(maxConnections > 0, 'maxConnections must be greater than zero'); +} + +/// A local dynamic forwarding server (SOCKS5 CONNECT) managed by [SSHClient]. +abstract class SSHDynamicForward { + /// Host/interface the local SOCKS server is bound to. + String get host; + + /// Bound local port of the SOCKS server. + int get port; + + /// Whether this forwarder has already been closed. + bool get isClosed; + + /// Stops accepting new SOCKS connections and closes active ones. + Future close(); +} + class SSHForwardChannel implements SSHSocket { final SSHChannel _channel; From 57f426a63a3e66e593f31b0fd1d86117e7f88440 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:14:19 +0100 Subject: [PATCH 06/24] test: cover dynamic forward socks flow --- test/src/socket/dynamic_forward_io_test.dart | 277 +++++++++++++++++++ test/src/ssh_client_test.dart | 19 ++ 2 files changed, 296 insertions(+) create mode 100644 test/src/socket/dynamic_forward_io_test.dart diff --git a/test/src/socket/dynamic_forward_io_test.dart b/test/src/socket/dynamic_forward_io_test.dart new file mode 100644 index 0000000..ec6db05 --- /dev/null +++ b/test/src/socket/dynamic_forward_io_test.dart @@ -0,0 +1,277 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dartssh2/src/dynamic_forward.dart'; +import 'package:dartssh2/src/message/msg_channel.dart'; +import 'package:dartssh2/src/ssh_channel.dart'; +import 'package:dartssh2/src/ssh_forward.dart'; +import 'package:test/test.dart'; + +void main() { + group('startDynamicForward (io)', () { + test('accepts SOCKS5 connect and proxies data', () async { + late _DialedTunnel dialed; + String? dialHost; + int? dialPort; + + final forward = await startDynamicForward( + bindHost: '127.0.0.1', + bindPort: 0, + options: const SSHDynamicForwardOptions(), + dial: (host, port) async { + dialHost = host; + dialPort = port; + dialed = _DialedTunnel.create(); + return dialed.channel; + }, + ); + + final client = await Socket.connect(forward.host, forward.port); + final incoming = client.asBroadcastStream(); + addTearDown(() async { + await client.close(); + await forward.close(); + dialed.dispose(); + }); + + await _sendGreeting(client, incoming); + final reply = + await _sendConnectDomain(client, incoming, 'example.com', 443); + + expect(reply[0], 0x05); + expect(reply[1], 0x00); + expect(dialHost, 'example.com'); + expect(dialPort, 443); + + client.add(utf8.encode('hello')); + await Future.delayed(const Duration(milliseconds: 20)); + expect(utf8.decode(dialed.sentToRemote), 'hello'); + + dialed.pushFromRemote(utf8.encode('world')); + final tunneled = await _readAtLeast(incoming, 5); + expect(utf8.decode(tunneled), 'world'); + }); + + test('rejects connection when filter returns false', () async { + var dialCalled = false; + + final forward = await startDynamicForward( + bindHost: '127.0.0.1', + bindPort: 0, + options: const SSHDynamicForwardOptions(), + filter: (_, __) => false, + dial: (_, __) async { + dialCalled = true; + return _DialedTunnel.create().channel; + }, + ); + addTearDown(() => forward.close()); + + final client = await Socket.connect(forward.host, forward.port); + final incoming = client.asBroadcastStream(); + addTearDown(() => client.close()); + + await _sendGreeting(client, incoming); + final reply = + await _sendConnectDomain(client, incoming, 'blocked.test', 80); + + expect(reply[1], 0x02); // connection not allowed + expect(dialCalled, isFalse); + }); + + test('rejects new connection when maxConnections is exceeded', () async { + final tunnels = <_DialedTunnel>[]; + + final forward = await startDynamicForward( + bindHost: '127.0.0.1', + bindPort: 0, + options: const SSHDynamicForwardOptions(maxConnections: 1), + dial: (_, __) async { + final tunnel = _DialedTunnel.create(); + tunnels.add(tunnel); + return tunnel.channel; + }, + ); + addTearDown(() async { + for (final tunnel in tunnels) { + tunnel.dispose(); + } + await forward.close(); + }); + + final first = await Socket.connect(forward.host, forward.port); + final firstIncoming = first.asBroadcastStream(); + addTearDown(() => first.close()); + await _sendGreeting(first, firstIncoming); + final firstReply = + await _sendConnectDomain(first, firstIncoming, 'one.test', 80); + expect(firstReply[1], 0x00); + + final second = await Socket.connect(forward.host, forward.port); + final secondIncoming = second.asBroadcastStream(); + addTearDown(() => second.close()); + await _sendGreeting(second, secondIncoming); + final secondReply = await _sendConnectDomain( + second, + secondIncoming, + 'two.test', + 80, + ); + expect(secondReply[1], 0x05); // connection refused + }); + + test('returns host unreachable when dial times out', () async { + final neverCompletes = Completer(); + + final forward = await startDynamicForward( + bindHost: '127.0.0.1', + bindPort: 0, + options: const SSHDynamicForwardOptions( + connectTimeout: Duration(milliseconds: 30), + ), + dial: (_, __) => neverCompletes.future, + ); + addTearDown(() => forward.close()); + + final client = await Socket.connect(forward.host, forward.port); + final incoming = client.asBroadcastStream(); + addTearDown(() => client.close()); + + await _sendGreeting(client, incoming); + final reply = + await _sendConnectDomain(client, incoming, 'timeout.test', 80); + + expect(reply[1], 0x04); // host unreachable + }); + + test('expires idle handshake when no greeting is sent', () async { + final forward = await startDynamicForward( + bindHost: '127.0.0.1', + bindPort: 0, + options: const SSHDynamicForwardOptions( + handshakeTimeout: Duration(milliseconds: 40), + ), + dial: (_, __) async => _DialedTunnel.create().channel, + ); + addTearDown(() => forward.close()); + + final client = await Socket.connect(forward.host, forward.port); + final incoming = client.asBroadcastStream(); + addTearDown(() => client.close()); + + final reply = await _readAtLeast(incoming, 10); + expect(reply[0], 0x05); + expect(reply[1], 0x06); // ttl expired + }); + }); +} + +Future _sendGreeting(Socket socket, Stream incoming) async { + socket.add([0x05, 0x01, 0x00]); + final greeting = await _readAtLeast(incoming, 2); + expect(greeting[0], 0x05); + expect(greeting[1], 0x00); +} + +Future _sendConnectDomain( + Socket socket, + Stream incoming, + String host, + int port, +) async { + final hostBytes = utf8.encode(host); + socket.add([ + 0x05, + 0x01, + 0x00, + 0x03, + hostBytes.length, + ...hostBytes, + (port >> 8) & 0xff, + port & 0xff, + ]); + return _readAtLeast(incoming, 10); +} + +Future _readAtLeast( + Stream incoming, + int minBytes, { + Duration timeout = const Duration(seconds: 1), +}) async { + final completer = Completer(); + final buffer = []; + late final StreamSubscription sub; + + sub = incoming.listen( + (chunk) { + buffer.addAll(chunk); + if (buffer.length >= minBytes && !completer.isCompleted) { + completer.complete(Uint8List.fromList(buffer)); + } + }, + onDone: () { + if (!completer.isCompleted) { + completer.complete(Uint8List.fromList(buffer)); + } + }, + onError: (Object error, StackTrace stackTrace) { + if (!completer.isCompleted) { + completer.completeError(error, stackTrace); + } + }, + cancelOnError: true, + ); + + try { + return await completer.future.timeout(timeout); + } finally { + await sub.cancel(); + } +} + +class _DialedTunnel { + _DialedTunnel._(this.channel, this._controller, this.sentToRemote); + + final SSHForwardChannel channel; + final SSHChannelController _controller; + final List sentToRemote; + + factory _DialedTunnel.create() { + final sentToRemote = []; + + final controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024 * 1024, + localInitialWindowSize: 1024 * 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024 * 1024, + remoteInitialWindowSize: 1024 * 1024, + sendMessage: (message) { + if (message is SSH_Message_Channel_Data) { + sentToRemote.addAll(message.data); + } + }, + ); + + return _DialedTunnel._( + SSHForwardChannel(controller.channel), + controller, + sentToRemote, + ); + } + + void pushFromRemote(List data) { + _controller.handleMessage( + SSH_Message_Channel_Data( + recipientChannel: _controller.localId, + data: Uint8List.fromList(data), + ), + ); + } + + void dispose() { + _controller.destroy(); + } +} diff --git a/test/src/ssh_client_test.dart b/test/src/ssh_client_test.dart index 0775069..4db35e1 100644 --- a/test/src/ssh_client_test.dart +++ b/test/src/ssh_client_test.dart @@ -163,6 +163,25 @@ void main() { }); }); + group('SSHClient.forwardDynamic', () { + test('starts and closes local dynamic forward', () async { + final client = await getTestClient(); + + final dynamicForward = await client.forwardDynamic( + bindHost: '127.0.0.1', + bindPort: 0, + ); + + expect(dynamicForward.port, greaterThan(0)); + expect(dynamicForward.isClosed, isFalse); + + await dynamicForward.close(); + expect(dynamicForward.isClosed, isTrue); + + client.close(); + }); + }); + group('SSHClient.runWithResult', () { test('returns command output and exit code', () async { final client = await getTestClient(); From 10f1da80bb78fc2f5685a2a57f1e3745a76c02c1 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:46:53 +0100 Subject: [PATCH 07/24] test: add comprehensive tests for dynamic SOCKS5 forwarding behavior --- test/src/socket/dynamic_forward_io_test.dart | 204 +++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/test/src/socket/dynamic_forward_io_test.dart b/test/src/socket/dynamic_forward_io_test.dart index ec6db05..f52fe7d 100644 --- a/test/src/socket/dynamic_forward_io_test.dart +++ b/test/src/socket/dynamic_forward_io_test.dart @@ -165,6 +165,210 @@ void main() { expect(reply[0], 0x05); expect(reply[1], 0x06); // ttl expired }); + + test('forwards pending bytes sent with CONNECT request', () async { + late _DialedTunnel dialed; + + final forward = await startDynamicForward( + bindHost: '127.0.0.1', + bindPort: 0, + options: const SSHDynamicForwardOptions(), + dial: (_, __) async { + dialed = _DialedTunnel.create(); + return dialed.channel; + }, + ); + + final client = await Socket.connect(forward.host, forward.port); + final incoming = client.asBroadcastStream(); + addTearDown(() async { + await client.close(); + await forward.close(); + dialed.dispose(); + }); + + await _sendGreeting(client, incoming); + + final hostBytes = utf8.encode('pending.test'); + client.add([ + 0x05, + 0x01, + 0x00, + 0x03, + hostBytes.length, + ...hostBytes, + 0x00, + 0x50, + ...utf8.encode('EXTRA'), + ]); + + final reply = await _readAtLeast(incoming, 10); + expect(reply[1], 0x00); + + await Future.delayed(const Duration(milliseconds: 20)); + expect(utf8.decode(dialed.sentToRemote), 'EXTRA'); + }); + + test('rejects unsupported greeting version', () async { + final forward = await startDynamicForward( + bindHost: '127.0.0.1', + bindPort: 0, + options: const SSHDynamicForwardOptions(), + dial: (_, __) async => _DialedTunnel.create().channel, + ); + addTearDown(() => forward.close()); + + final client = await Socket.connect(forward.host, forward.port); + final incoming = client.asBroadcastStream(); + addTearDown(() => client.close()); + + client.add([0x04, 0x01, 0x00]); + final reply = await _readAtLeast(incoming, 2); + expect(reply[0], 0x05); + expect(reply[1], 0xFF); + }); + + test('rejects unsupported authentication method', () async { + final forward = await startDynamicForward( + bindHost: '127.0.0.1', + bindPort: 0, + options: const SSHDynamicForwardOptions(), + dial: (_, __) async => _DialedTunnel.create().channel, + ); + addTearDown(() => forward.close()); + + final client = await Socket.connect(forward.host, forward.port); + final incoming = client.asBroadcastStream(); + addTearDown(() => client.close()); + + client.add([0x05, 0x01, 0x02]); + final reply = await _readAtLeast(incoming, 2); + expect(reply[0], 0x05); + expect(reply[1], 0xFF); + }); + + test('rejects unsupported request version', () async { + final forward = await startDynamicForward( + bindHost: '127.0.0.1', + bindPort: 0, + options: const SSHDynamicForwardOptions(), + dial: (_, __) async => _DialedTunnel.create().channel, + ); + addTearDown(() => forward.close()); + + final client = await Socket.connect(forward.host, forward.port); + final incoming = client.asBroadcastStream(); + addTearDown(() => client.close()); + + await _sendGreeting(client, incoming); + client.add([0x04, 0x01, 0x00, 0x01, 127, 0, 0, 1, 0, 22]); + final reply = await _readAtLeast(incoming, 10); + expect(reply[1], 0x01); + }); + + test('rejects unsupported request command', () async { + final forward = await startDynamicForward( + bindHost: '127.0.0.1', + bindPort: 0, + options: const SSHDynamicForwardOptions(), + dial: (_, __) async => _DialedTunnel.create().channel, + ); + addTearDown(() => forward.close()); + + final client = await Socket.connect(forward.host, forward.port); + final incoming = client.asBroadcastStream(); + addTearDown(() => client.close()); + + await _sendGreeting(client, incoming); + client.add([0x05, 0x02, 0x00, 0x01, 127, 0, 0, 1, 0, 22]); + final reply = await _readAtLeast(incoming, 10); + expect(reply[1], 0x07); + }); + + test('rejects unsupported address type', () async { + final forward = await startDynamicForward( + bindHost: '127.0.0.1', + bindPort: 0, + options: const SSHDynamicForwardOptions(), + dial: (_, __) async => _DialedTunnel.create().channel, + ); + addTearDown(() => forward.close()); + + final client = await Socket.connect(forward.host, forward.port); + final incoming = client.asBroadcastStream(); + addTearDown(() => client.close()); + + await _sendGreeting(client, incoming); + client.add([0x05, 0x01, 0x00, 0x7F, 0x00, 0x00]); + final reply = await _readAtLeast(incoming, 10); + expect(reply[1], 0x08); + }); + + test('supports IPv4 and IPv6 target addresses', () async { + final tunnels = <_DialedTunnel>[]; + final dialedHosts = []; + + final forward = await startDynamicForward( + bindHost: '127.0.0.1', + bindPort: 0, + options: const SSHDynamicForwardOptions(), + dial: (host, _) async { + dialedHosts.add(host); + final tunnel = _DialedTunnel.create(); + tunnels.add(tunnel); + return tunnel.channel; + }, + ); + addTearDown(() async { + for (final tunnel in tunnels) { + tunnel.dispose(); + } + await forward.close(); + }); + + final ipv4 = await Socket.connect(forward.host, forward.port); + final ipv4Incoming = ipv4.asBroadcastStream(); + addTearDown(() => ipv4.close()); + await _sendGreeting(ipv4, ipv4Incoming); + ipv4.add([0x05, 0x01, 0x00, 0x01, 192, 168, 1, 2, 0, 80]); + final ipv4Reply = await _readAtLeast(ipv4Incoming, 10); + expect(ipv4Reply[1], 0x00); + + final ipv6 = await Socket.connect(forward.host, forward.port); + final ipv6Incoming = ipv6.asBroadcastStream(); + addTearDown(() => ipv6.close()); + await _sendGreeting(ipv6, ipv6Incoming); + ipv6.add([ + 0x05, + 0x01, + 0x00, + 0x04, + 0x20, + 0x01, + 0x0d, + 0xb8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 22, + ]); + final ipv6Reply = await _readAtLeast(ipv6Incoming, 10); + expect(ipv6Reply[1], 0x00); + + expect(dialedHosts.length, 2); + expect(dialedHosts[0], '192.168.1.2'); + expect(dialedHosts[1], contains(':')); + }); }); } From 080d6d4b90a51ecb2e305596b5dadc1b92810297 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:52:12 +0100 Subject: [PATCH 08/24] test: add unit tests for SSHClient.forwardDynamic authentication flow --- test/src/ssh_client_forward_dynamic_test.dart | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 test/src/ssh_client_forward_dynamic_test.dart diff --git a/test/src/ssh_client_forward_dynamic_test.dart b/test/src/ssh_client_forward_dynamic_test.dart new file mode 100644 index 0000000..e1e3b89 --- /dev/null +++ b/test/src/ssh_client_forward_dynamic_test.dart @@ -0,0 +1,86 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/src/message/msg_userauth.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSHClient.forwardDynamic', () { + test('waits for authentication before starting', () async { + final client = SSHClient( + _FakeSSHSocket(), + username: 'demo', + keepAliveInterval: null, + ); + + // Simulate server auth success so forwardDynamic can proceed. + scheduleMicrotask(() { + client.handlePacket(SSH_Message_Userauth_Success().encode()); + }); + + final dynamicForward = await client.forwardDynamic( + bindHost: '127.0.0.1', + bindPort: 0, + ); + + expect(dynamicForward.port, greaterThan(0)); + expect(dynamicForward.isClosed, isFalse); + + await dynamicForward.close(); + expect(dynamicForward.isClosed, isTrue); + + client.close(); + await client.done; + }); + }); +} + +class _FakeSSHSocket implements SSHSocket { + final _inputController = StreamController(); + final _doneCompleter = Completer(); + + @override + Stream get stream => _inputController.stream; + + @override + StreamSink> get sink => _NoopSink(); + + @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 _NoopSink implements StreamSink> { + @override + void add(List data) {} + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream> stream) async { + await for (final _ in stream) {} + } + + @override + Future close() async {} + + @override + Future get done async {} +} From b54f461730db594cc53dbac787882e59aa64d224 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:05:16 +0100 Subject: [PATCH 09/24] feat: start AEAD groundwork with AES-GCM transport support --- lib/src/algorithm/ssh_cipher_type.dart | 50 ++++- lib/src/ssh_algorithm.dart | 2 + lib/src/ssh_transport.dart | 190 +++++++++++++++++-- test/src/algorithm/ssh_cipher_type_test.dart | 22 +++ 4 files changed, 240 insertions(+), 24 deletions(-) diff --git a/lib/src/algorithm/ssh_cipher_type.dart b/lib/src/algorithm/ssh_cipher_type.dart index c59db33..740f9df 100644 --- a/lib/src/algorithm/ssh_cipher_type.dart +++ b/lib/src/algorithm/ssh_cipher_type.dart @@ -5,6 +5,8 @@ import 'package:pointycastle/export.dart'; class SSHCipherType extends SSHAlgorithm { static const values = [ + aes128gcm, + aes256gcm, aes128cbc, aes192cbc, aes256cbc, @@ -31,6 +33,24 @@ class SSHCipherType extends SSHAlgorithm { cipherFactory: _aesCtrFactory, ); + static const aes128gcm = SSHCipherType._( + name: 'aes128-gcm@openssh.com', + keySize: 16, + isAead: true, + ivSize: 12, + blockSize: 16, + aeadTagSize: 16, + ); + + static const aes256gcm = SSHCipherType._( + name: 'aes256-gcm@openssh.com', + keySize: 32, + isAead: true, + ivSize: 12, + blockSize: 16, + aeadTagSize: 16, + ); + static const aes128cbc = SSHCipherType._( name: 'aes128-cbc', keySize: 16, @@ -61,7 +81,11 @@ class SSHCipherType extends SSHAlgorithm { const SSHCipherType._({ required this.name, required this.keySize, - required this.cipherFactory, + this.cipherFactory, + this.isAead = false, + this.aeadTagSize = 0, + this.ivSize = 16, + this.blockSize = 16, }); /// The name of the algorithm. For example, `"aes256-ctr`"`. @@ -70,17 +94,29 @@ class SSHCipherType extends SSHAlgorithm { final int keySize; - final int ivSize = 16; + /// Indicates whether this cipher is an AEAD mode (e.g. AES-GCM). + final bool isAead; - final int blockSize = 16; + /// Authentication tag size for AEAD ciphers. + final int aeadTagSize; - final BlockCipher Function() cipherFactory; + final int ivSize; + + final int blockSize; + + final BlockCipher Function()? cipherFactory; BlockCipher createCipher( Uint8List key, Uint8List iv, { required bool forEncryption, }) { + if (isAead) { + throw UnsupportedError( + 'AEAD ciphers are packet-level and do not expose BlockCipher', + ); + } + if (key.length != keySize) { throw ArgumentError.value(key, 'key', 'Key must be $keySize bytes long'); } @@ -89,7 +125,11 @@ class SSHCipherType extends SSHAlgorithm { throw ArgumentError.value(iv, 'iv', 'IV must be $ivSize bytes long'); } - final cipher = cipherFactory(); + final factory = cipherFactory; + if (factory == null) { + throw StateError('No block cipher factory configured for $name'); + } + final cipher = factory(); cipher.init(forEncryption, ParametersWithIV(KeyParameter(key), iv)); return cipher; } diff --git a/lib/src/ssh_algorithm.dart b/lib/src/ssh_algorithm.dart index fc57995..2117e18 100644 --- a/lib/src/ssh_algorithm.dart +++ b/lib/src/ssh_algorithm.dart @@ -65,6 +65,8 @@ class SSHAlgorithms { SSHHostkeyType.ecdsa256, ], this.cipher = const [ + SSHCipherType.aes128gcm, + SSHCipherType.aes256gcm, SSHCipherType.aes128ctr, SSHCipherType.aes128cbc, SSHCipherType.aes256ctr, diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index 6df9e9c..d66a245 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -167,6 +167,14 @@ class SSHTransport { /// A [BlockCipher] to decrypt data sent from the other side. BlockCipher? _decryptCipher; + Uint8List? _localCipherKey; + + Uint8List? _remoteCipherKey; + + Uint8List? _localIV; + + Uint8List? _remoteIV; + /// A [Mac] used to authenticate data sent to the other side. Mac? _localMac; @@ -201,6 +209,17 @@ class SSHTransport { final clientMacType = _clientMacType; final serverMacType = _serverMacType; final macType = isClient ? clientMacType : serverMacType; + final localCipherType = isClient ? _clientCipherType : _serverCipherType; + + if (localCipherType != null && + localCipherType.isAead && + _localCipherKey != null && + _localIV != null) { + _sendAeadPacket(data, localCipherType); + _localPacketSN.increase(); + return; + } + final isEtm = _encryptCipher != null && macType != null && macType.isEtm; // For ETM, we need to handle the packet differently @@ -293,6 +312,72 @@ class SSHTransport { _localPacketSN.increase(); } + void _sendAeadPacket(Uint8List data, SSHCipherType cipherType) { + final paddingLength = + _alignedPaddingLength(data.length, cipherType.blockSize); + final packetLength = 1 + data.length + paddingLength; + + final aad = Uint8List(4)..buffer.asByteData().setUint32(0, packetLength); + + final plaintext = Uint8List(packetLength) + ..[0] = paddingLength + ..setRange(1, 1 + data.length, data); + + for (var i = 0; i < paddingLength; i++) { + plaintext[1 + data.length + i] = + (DateTime.now().microsecondsSinceEpoch + i) & 0xff; + } + + final encrypted = _processAead( + key: _localCipherKey!, + iv: _localIV!, + sequence: _localPacketSN.value, + aad: aad, + input: plaintext, + forEncryption: true, + ); + + final buffer = BytesBuilder(copy: false) + ..add(aad) + ..add(encrypted); + + socket.sink.add(buffer.takeBytes()); + } + + int _alignedPaddingLength(int payloadLength, int align) { + final paddingLength = align - ((payloadLength + 1) % align); + return paddingLength < 4 ? paddingLength + align : paddingLength; + } + + Uint8List _processAead({ + required Uint8List key, + required Uint8List iv, + required int sequence, + required Uint8List aad, + required Uint8List input, + required bool forEncryption, + }) { + final cipher = GCMBlockCipher(AESEngine()); + final nonce = _nonceForSequence(iv, sequence); + cipher.init( + forEncryption, + AEADParameters(KeyParameter(key), 128, nonce, aad), + ); + return cipher.process(input); + } + + Uint8List _nonceForSequence(Uint8List iv, int sequence) { + if (iv.length != 12) { + throw ArgumentError.value(iv, 'iv', 'AEAD IV must be 12 bytes long'); + } + + final nonce = Uint8List.fromList(iv); + final view = ByteData.sublistView(nonce); + final counter = view.getUint64(4); + view.setUint64(4, counter + sequence); + return nonce; + } + void close() { printDebug?.call('SSHTransport.close'); if (isClosed) return; @@ -416,7 +501,7 @@ class SSHTransport { /// WITHOUT `packet length`, `padding length`, `padding` and `MAC`. Returns /// `null` if there is not enough data in the buffer to read the packet. Uint8List? _consumePacket() { - return _decryptCipher == null + return (_decryptCipher == null && _remoteCipherKey == null) ? _consumeClearTextPacket() : _consumeEncryptedPacket(); } @@ -446,6 +531,14 @@ class SSHTransport { Uint8List? _consumeEncryptedPacket() { printDebug?.call('SSHTransport._consumeEncryptedPacket'); + final remoteCipherType = isClient ? _serverCipherType : _clientCipherType; + if (remoteCipherType != null && + remoteCipherType.isAead && + _remoteCipherKey != null && + _remoteIV != null) { + return _consumeAeadPacket(remoteCipherType); + } + final blockSize = _decryptCipher!.blockSize; if (_buffer.length < blockSize) { return null; @@ -557,6 +650,47 @@ class SSHTransport { } } + Uint8List? _consumeAeadPacket(SSHCipherType cipherType) { + if (_buffer.length < 4) { + return null; + } + + final packetLength = SSHPacket.readPacketLength(_buffer.data); + _verifyPacketLength(packetLength); + + final tagLength = cipherType.aeadTagSize; + if (_buffer.length < 4 + packetLength + tagLength) { + return null; + } + + final aad = _buffer.consume(4); + final ciphertext = _buffer.consume(packetLength); + final tag = _buffer.consume(tagLength); + + final encryptedInput = Uint8List(packetLength + tagLength) + ..setRange(0, packetLength, ciphertext) + ..setRange(packetLength, packetLength + tagLength, tag); + + late Uint8List plaintext; + try { + plaintext = _processAead( + key: _remoteCipherKey!, + iv: _remoteIV!, + sequence: _remotePacketSN.value, + aad: aad, + input: encryptedInput, + forEncryption: false, + ); + } on InvalidCipherTextException { + throw SSHPacketError('AEAD authentication failed'); + } + + final paddingLength = plaintext[0]; + final payloadLength = packetLength - paddingLength - 1; + _verifyPacketPadding(payloadLength, paddingLength); + return Uint8List.sublistView(plaintext, 1, 1 + payloadLength); + } + void _verifyPacketLength(int packetLength) { if (packetLength > SSHPacket.maxLength) { throw SSHPacketError('Packet too long: $packetLength'); @@ -632,15 +766,24 @@ class SSHTransport { final cipherType = isClient ? _clientCipherType : _serverCipherType; if (cipherType == null) throw StateError('No cipher type selected'); + _localCipherKey = _deriveKey( + isClient ? SSHDeriveKeyType.clientKey : SSHDeriveKeyType.serverKey, + cipherType.keySize, + ); + _localIV = _deriveKey( + isClient ? SSHDeriveKeyType.clientIV : SSHDeriveKeyType.serverIV, + cipherType.ivSize, + ); + + if (cipherType.isAead) { + _encryptCipher = null; + _localMac = null; + return; + } + _encryptCipher = cipherType.createCipher( - _deriveKey( - isClient ? SSHDeriveKeyType.clientKey : SSHDeriveKeyType.serverKey, - cipherType.keySize, - ), - _deriveKey( - isClient ? SSHDeriveKeyType.clientIV : SSHDeriveKeyType.serverIV, - cipherType.ivSize, - ), + _localCipherKey!, + _localIV!, forEncryption: true, ); @@ -659,15 +802,24 @@ class SSHTransport { final cipherType = isClient ? _serverCipherType : _clientCipherType; if (cipherType == null) throw StateError('No cipher type selected'); + _remoteCipherKey = _deriveKey( + isClient ? SSHDeriveKeyType.serverKey : SSHDeriveKeyType.clientKey, + cipherType.keySize, + ); + _remoteIV = _deriveKey( + isClient ? SSHDeriveKeyType.serverIV : SSHDeriveKeyType.clientIV, + cipherType.ivSize, + ); + + if (cipherType.isAead) { + _decryptCipher = null; + _remoteMac = null; + return; + } + _decryptCipher = cipherType.createCipher( - _deriveKey( - isClient ? SSHDeriveKeyType.serverKey : SSHDeriveKeyType.clientKey, - cipherType.keySize, - ), - _deriveKey( - isClient ? SSHDeriveKeyType.serverIV : SSHDeriveKeyType.clientIV, - cipherType.ivSize, - ), + _remoteCipherKey!, + _remoteIV!, forEncryption: false, ); @@ -905,10 +1057,10 @@ class SSHTransport { if (_serverCipherType == null) { throw StateError('No matching server cipher algorithm'); } - if (_clientMacType == null) { + if (_clientMacType == null && !_clientCipherType!.isAead) { throw StateError('No matching client MAC algorithm'); } - if (_serverMacType == null) { + if (_serverMacType == null && !_serverCipherType!.isAead) { throw StateError('No matching server MAC algorithm'); } diff --git a/test/src/algorithm/ssh_cipher_type_test.dart b/test/src/algorithm/ssh_cipher_type_test.dart index 45019c3..f6756ff 100644 --- a/test/src/algorithm/ssh_cipher_type_test.dart +++ b/test/src/algorithm/ssh_cipher_type_test.dart @@ -40,6 +40,26 @@ void main() { }); }); + group('AEAD cipher metadata', () { + test('AES-GCM ciphers are marked as AEAD', () { + expect(SSHCipherType.aes128gcm.isAead, isTrue); + expect(SSHCipherType.aes256gcm.isAead, isTrue); + expect(SSHCipherType.aes128gcm.ivSize, 12); + expect(SSHCipherType.aes128gcm.aeadTagSize, 16); + }); + + test('AEAD ciphers do not expose BlockCipher API', () { + expect( + () => SSHCipherType.aes128gcm.createCipher( + Uint8List(SSHCipherType.aes128gcm.keySize), + Uint8List(SSHCipherType.aes128gcm.ivSize), + forEncryption: true, + ), + throwsA(isA()), + ); + }); + }); + test('Default values are set correctly', () { final algorithms = SSHAlgorithms(); @@ -72,6 +92,8 @@ void main() { expect( algorithms.cipher, equals([ + SSHCipherType.aes128gcm, + SSHCipherType.aes256gcm, SSHCipherType.aes128ctr, SSHCipherType.aes128cbc, SSHCipherType.aes256ctr, From d60caf3bd27ff96a3aa8efc1f36a18c4bad0cd4c Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:07:41 +0100 Subject: [PATCH 10/24] feat: update CHANGELOG and README to include AES-GCM AEAD groundwork and cipher support --- CHANGELOG.md | 2 ++ README.md | 3 +++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d5529..38c6025 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [2.17.0] - yyyy-mm-dd - Improved Web/WASM compatibility by updating `SSHSocket` conditional imports so web runtimes consistently use the web socket shim and avoid incorrect native socket selection [#88]. Thanks [@vicajilau]. - Added local dynamic forwarding (`SSHClient.forwardDynamic`) with SOCKS5 `NO AUTH` + `CONNECT`, including configurable handshake/connect timeouts and connection limits. +- Added AES-GCM (`aes128-gcm@openssh.com`, `aes256-gcm@openssh.com`) AEAD groundwork in transport and cipher negotiation; `chacha20-poly1305@openssh.com` remains pending [#26]. Thanks [@vicajilau]. ## [2.16.0] - 2026-03-24 - **BREAKING**: Changed `SSHChannelController.sendEnv()` from `void` to `Future` to properly await environment variable setup responses and avoid race conditions with PTY requests [#102]. Thanks [@itzhoujun] and [@vicajilau]. @@ -208,6 +209,7 @@ [#124]: https://github.com/TerminalStudio/dartssh2/issues/124 [#95]: https://github.com/TerminalStudio/dartssh2/issues/95 [#88]: https://github.com/TerminalStudio/dartssh2/issues/88 +[#26]: https://github.com/TerminalStudio/dartssh2/issues/26 [#139]: https://github.com/TerminalStudio/dartssh2/pull/139 [#132]: https://github.com/TerminalStudio/dartssh2/pull/132 [#133]: https://github.com/TerminalStudio/dartssh2/pull/133 diff --git a/README.md b/README.md index 53ea6e2..eec1bdd 100644 --- a/README.md +++ b/README.md @@ -625,9 +625,12 @@ void main() async { - `diffie-hellman-group1-sha1 ` **Cipher**: +- `aes[128|256]-gcm@openssh.com` - `aes[128|192|256]-ctr` - `aes[128|192|256]-cbc` +`chacha20-poly1305@openssh.com` is not supported yet. + **Integrity**: - `hmac-md5` - `hmac-sha1` From 2e7887b5c85fe09eef0d9fe6fdf1fb946a4e0ae4 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:11:58 +0100 Subject: [PATCH 11/24] fix: keep AES-GCM opt-in to avoid auth regressions --- lib/src/ssh_algorithm.dart | 2 -- test/src/algorithm/ssh_cipher_type_test.dart | 2 -- 2 files changed, 4 deletions(-) diff --git a/lib/src/ssh_algorithm.dart b/lib/src/ssh_algorithm.dart index 2117e18..fc57995 100644 --- a/lib/src/ssh_algorithm.dart +++ b/lib/src/ssh_algorithm.dart @@ -65,8 +65,6 @@ class SSHAlgorithms { SSHHostkeyType.ecdsa256, ], this.cipher = const [ - SSHCipherType.aes128gcm, - SSHCipherType.aes256gcm, SSHCipherType.aes128ctr, SSHCipherType.aes128cbc, SSHCipherType.aes256ctr, diff --git a/test/src/algorithm/ssh_cipher_type_test.dart b/test/src/algorithm/ssh_cipher_type_test.dart index f6756ff..114efe9 100644 --- a/test/src/algorithm/ssh_cipher_type_test.dart +++ b/test/src/algorithm/ssh_cipher_type_test.dart @@ -92,8 +92,6 @@ void main() { expect( algorithms.cipher, equals([ - SSHCipherType.aes128gcm, - SSHCipherType.aes256gcm, SSHCipherType.aes128ctr, SSHCipherType.aes128cbc, SSHCipherType.aes256ctr, From 20bad41c3ee692de8f03d6af9b65086af7c50c45 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:21:30 +0100 Subject: [PATCH 12/24] feat: update README and CHANGELOG to clarify AES-GCM opt-in status and provide usage example --- CHANGELOG.md | 2 +- README.md | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c6025..91a1842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## [2.17.0] - yyyy-mm-dd - Improved Web/WASM compatibility by updating `SSHSocket` conditional imports so web runtimes consistently use the web socket shim and avoid incorrect native socket selection [#88]. Thanks [@vicajilau]. - Added local dynamic forwarding (`SSHClient.forwardDynamic`) with SOCKS5 `NO AUTH` + `CONNECT`, including configurable handshake/connect timeouts and connection limits. -- Added AES-GCM (`aes128-gcm@openssh.com`, `aes256-gcm@openssh.com`) AEAD groundwork in transport and cipher negotiation; `chacha20-poly1305@openssh.com` remains pending [#26]. Thanks [@vicajilau]. +- Added AES-GCM (`aes128-gcm@openssh.com`, `aes256-gcm@openssh.com`) AEAD groundwork in transport and cipher negotiation; currently opt-in (not enabled by default yet). `chacha20-poly1305@openssh.com` remains pending [#26]. Thanks [@vicajilau]. ## [2.16.0] - 2026-03-24 - **BREAKING**: Changed `SSHChannelController.sendEnv()` from `void` to `Future` to properly await environment variable setup responses and avoid race conditions with PTY requests [#102]. Thanks [@itzhoujun] and [@vicajilau]. diff --git a/README.md b/README.md index eec1bdd..ce38b86 100644 --- a/README.md +++ b/README.md @@ -629,6 +629,33 @@ void main() async { - `aes[128|192|256]-ctr` - `aes[128|192|256]-cbc` +AES-GCM is currently available as opt-in via `SSHAlgorithms(cipher: ...)`, and is not enabled in the default cipher preference list yet. + +Example (opt-in AES-GCM with explicit fallback ciphers): + +```dart +void main() async { + final client = SSHClient( + await SSHSocket.connect('localhost', 22), + username: '', + onPasswordRequest: () => '', + algorithms: const SSHAlgorithms( + cipher: [ + SSHCipherType.aes256gcm, + SSHCipherType.aes128gcm, + SSHCipherType.aes256ctr, + SSHCipherType.aes128ctr, + SSHCipherType.aes256cbc, + SSHCipherType.aes128cbc, + ], + ), + ); + + // Use the client... + client.close(); +} +``` + `chacha20-poly1305@openssh.com` is not supported yet. **Integrity**: From 6616eb90479f0baf098bfc6bb3496b8820947733 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 27 Mar 2026 07:57:55 +0100 Subject: [PATCH 13/24] feat: add tests for AES-GCM cipher resolution and SSHTransport AEAD functionality --- test/src/algorithm/ssh_cipher_type_test.dart | 11 + test/src/ssh_transport_aead_test.dart | 226 +++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 test/src/ssh_transport_aead_test.dart diff --git a/test/src/algorithm/ssh_cipher_type_test.dart b/test/src/algorithm/ssh_cipher_type_test.dart index 114efe9..3b096a3 100644 --- a/test/src/algorithm/ssh_cipher_type_test.dart +++ b/test/src/algorithm/ssh_cipher_type_test.dart @@ -58,6 +58,17 @@ void main() { throwsA(isA()), ); }); + + test('fromName resolves AES-GCM ciphers', () { + expect( + SSHCipherType.fromName('aes128-gcm@openssh.com'), + SSHCipherType.aes128gcm, + ); + expect( + SSHCipherType.fromName('aes256-gcm@openssh.com'), + SSHCipherType.aes256gcm, + ); + }); }); test('Default values are set correctly', () { diff --git a/test/src/ssh_transport_aead_test.dart b/test/src/ssh_transport_aead_test.dart new file mode 100644 index 0000000..1f06476 --- /dev/null +++ b/test/src/ssh_transport_aead_test.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'dart:mirrors'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/src/ssh_algorithm.dart'; +import 'package:dartssh2/src/ssh_errors.dart'; +import 'package:dartssh2/src/ssh_packet.dart'; +import 'package:dartssh2/src/ssh_transport.dart'; +import 'package:test/test.dart'; + +void main() { + final transportLibrary = reflectClass(SSHTransport).owner as LibraryMirror; + final packetLibrary = reflectClass(SSHPacketSN).owner as LibraryMirror; + Symbol privateSymbol(String name) => + MirrorSystem.getSymbol(name, transportLibrary); + Symbol packetPrivateSymbol(String name) => + MirrorSystem.getSymbol(name, packetLibrary); + void setPrivate(SSHTransport transport, String field, Object? value) { + reflect(transport).setField(privateSymbol(field), value); + } + + void setSequenceValue(SSHTransport transport, String field, int value) { + final sequence = + reflect(transport).getField(privateSymbol(field)).reflectee; + reflect(sequence).setField(packetPrivateSymbol('_value'), value); + } + + group('SSHTransport AEAD', () { + test('exchanges packets with AES-GCM', () async { + final key = Uint8List(16); + final iv = Uint8List(12); + for (var i = 0; i < key.length; i++) { + key[i] = i; + } + for (var i = 0; i < iv.length; i++) { + iv[i] = i + 16; + } + + final senderSocket = _CaptureSSHSocket(); + final sender = SSHTransport( + senderSocket, + algorithms: const SSHAlgorithms( + cipher: [SSHCipherType.aes128gcm], + ), + ); + + setPrivate(sender, '_clientCipherType', SSHCipherType.aes128gcm); + setPrivate(sender, '_localCipherKey', key); + setPrivate(sender, '_localIV', iv); + setPrivate(sender, '_kexInProgress', false); + setSequenceValue(sender, '_localPacketSN', 0); + + final payload = Uint8List.fromList([250, 1, 2, 3, 4, 5]); + sender.sendPacket(payload); + + final encryptedPacket = senderSocket.packets.last; + + final receiverSocket = _CaptureSSHSocket(); + final receivedPacket = Completer(); + final receiver = SSHTransport( + receiverSocket, + algorithms: const SSHAlgorithms( + cipher: [SSHCipherType.aes128gcm], + ), + onPacket: (packet) { + if (!receivedPacket.isCompleted) { + receivedPacket.complete(packet); + } + }, + ); + + setPrivate(receiver, '_remoteVersion', 'SSH-2.0-test'); + setPrivate(receiver, '_serverCipherType', SSHCipherType.aes128gcm); + setPrivate(receiver, '_remoteCipherKey', key); + setPrivate(receiver, '_remoteIV', iv); + setSequenceValue(receiver, '_remotePacketSN', 0); + + receiverSocket.addIncomingBytes(encryptedPacket); + + final received = + await receivedPacket.future.timeout(const Duration(seconds: 2)); + expect(received, payload); + + sender.close(); + receiver.close(); + }); + + test('reports AEAD authentication failure when packet is tampered', + () async { + final key = Uint8List(16); + final iv = Uint8List(12); + for (var i = 0; i < key.length; i++) { + key[i] = i; + } + for (var i = 0; i < iv.length; i++) { + iv[i] = i + 16; + } + + final senderSocket = _CaptureSSHSocket(); + final sender = SSHTransport( + senderSocket, + algorithms: const SSHAlgorithms( + cipher: [SSHCipherType.aes128gcm], + ), + ); + + setPrivate(sender, '_clientCipherType', SSHCipherType.aes128gcm); + setPrivate(sender, '_localCipherKey', key); + setPrivate(sender, '_localIV', iv); + setPrivate(sender, '_kexInProgress', false); + setSequenceValue(sender, '_localPacketSN', 0); + + sender.sendPacket(Uint8List.fromList([251, 9, 8, 7])); + final tampered = Uint8List.fromList(senderSocket.packets.last); + tampered[tampered.length - 1] ^= 0x01; + + final receiverSocket = _CaptureSSHSocket(); + final receiver = SSHTransport( + receiverSocket, + algorithms: const SSHAlgorithms( + cipher: [SSHCipherType.aes128gcm], + ), + ); + + setPrivate(receiver, '_remoteVersion', 'SSH-2.0-test'); + setPrivate(receiver, '_serverCipherType', SSHCipherType.aes128gcm); + setPrivate(receiver, '_remoteCipherKey', key); + setPrivate(receiver, '_remoteIV', iv); + setSequenceValue(receiver, '_remotePacketSN', 0); + + receiverSocket.addIncomingBytes(tampered); + + await expectLater( + receiver.done, + throwsA( + predicate( + (error) => + error is SSHPacketError && + error.toString().contains('AEAD authentication failed'), + ), + ), + ); + + sender.close(); + receiver.close(); + }); + + test('validates AEAD nonce IV length', () { + final socket = _CaptureSSHSocket(); + final transport = SSHTransport(socket); + + expect( + () => reflect(transport).invoke( + privateSymbol('_nonceForSequence'), + [Uint8List(8), 0], + ), + throwsA(isA()), + ); + + transport.close(); + }); + }); +} + +class _CaptureSSHSocket implements SSHSocket { + final _inputController = StreamController(); + final _doneCompleter = Completer(); + final packets = []; + + @override + Stream get stream => _inputController.stream; + + @override + StreamSink> get sink => _CaptureSink(packets); + + @override + Future get done => _doneCompleter.future; + + void addIncomingBytes(Uint8List data) { + _inputController.add(Uint8List.fromList(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 _CaptureSink implements StreamSink> { + _CaptureSink(this._packets); + + final List _packets; + + @override + void add(List data) { + _packets.add(Uint8List.fromList(data)); + } + + @override + Future addStream(Stream> stream) async { + await for (final chunk in stream) { + add(chunk); + } + } + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future close() async {} + + @override + Future get done async {} +} From 9182d10fea945e3c1fff422c7094cb02879522fa Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 27 Mar 2026 07:59:36 +0100 Subject: [PATCH 14/24] refactor: remove unused imports in SSHTransport AEAD test --- test/src/ssh_transport_aead_test.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/src/ssh_transport_aead_test.dart b/test/src/ssh_transport_aead_test.dart index 1f06476..ce1d99a 100644 --- a/test/src/ssh_transport_aead_test.dart +++ b/test/src/ssh_transport_aead_test.dart @@ -3,10 +3,7 @@ import 'dart:mirrors'; import 'dart:typed_data'; import 'package:dartssh2/dartssh2.dart'; -import 'package:dartssh2/src/ssh_algorithm.dart'; -import 'package:dartssh2/src/ssh_errors.dart'; import 'package:dartssh2/src/ssh_packet.dart'; -import 'package:dartssh2/src/ssh_transport.dart'; import 'package:test/test.dart'; void main() { From ea6766fc83526fc56ec3a525f8981aa7130535d4 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:07:59 +0100 Subject: [PATCH 15/24] feat: add AEAD cipher tests and enhance SSHTransport functionality --- test/src/algorithm/ssh_cipher_type_test.dart | 50 +++ test/src/ssh_transport_aead_test.dart | 312 +++++++++++++++++++ 2 files changed, 362 insertions(+) diff --git a/test/src/algorithm/ssh_cipher_type_test.dart b/test/src/algorithm/ssh_cipher_type_test.dart index 3b096a3..132b226 100644 --- a/test/src/algorithm/ssh_cipher_type_test.dart +++ b/test/src/algorithm/ssh_cipher_type_test.dart @@ -1,4 +1,5 @@ import 'dart:typed_data'; +import 'dart:mirrors'; import 'package:dartssh2/dartssh2.dart'; import 'package:dartssh2/src/ssh_algorithm.dart'; @@ -69,6 +70,33 @@ void main() { SSHCipherType.aes256gcm, ); }); + + test('createCipher throws when cipher factory is missing', () { + final library = reflectClass(SSHCipherType).owner as LibraryMirror; + final ctor = MirrorSystem.getSymbol('_', library); + final dynamic custom = reflectClass(SSHCipherType).newInstance( + ctor, + const [], + { + #name: 'custom-null-factory', + #keySize: 16, + #ivSize: 16, + #blockSize: 16, + #isAead: false, + #aeadTagSize: 0, + #cipherFactory: null, + }, + ).reflectee; + + expect( + () => custom.createCipher( + Uint8List(16), + Uint8List(16), + forEncryption: true, + ), + throwsA(isA()), + ); + }); }); test('Default values are set correctly', () { @@ -142,6 +170,28 @@ void testCipher(SSHCipherType type) { expect(decrypted, plainText); }); + test('$type rejects invalid key length', () { + expect( + () => type.createCipher( + Uint8List(type.keySize - 1), + Uint8List(type.blockSize), + forEncryption: true, + ), + throwsA(isA()), + ); + }); + + test('$type rejects invalid IV length', () { + expect( + () => type.createCipher( + Uint8List(type.keySize), + Uint8List(type.ivSize - 1), + forEncryption: true, + ), + throwsA(isA()), + ); + }); + // test('$type needs init after reset', () { // final key = Uint8List(type.keySize); // final iv = Uint8List(type.blockSize); diff --git a/test/src/ssh_transport_aead_test.dart b/test/src/ssh_transport_aead_test.dart index ce1d99a..6718738 100644 --- a/test/src/ssh_transport_aead_test.dart +++ b/test/src/ssh_transport_aead_test.dart @@ -3,6 +3,7 @@ import 'dart:mirrors'; import 'dart:typed_data'; import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/src/message/msg_kex.dart'; import 'package:dartssh2/src/ssh_packet.dart'; import 'package:test/test.dart'; @@ -17,6 +18,10 @@ void main() { reflect(transport).setField(privateSymbol(field), value); } + T getPrivate(SSHTransport transport, String field) { + return reflect(transport).getField(privateSymbol(field)).reflectee as T; + } + void setSequenceValue(SSHTransport transport, String field, int value) { final sequence = reflect(transport).getField(privateSymbol(field)).reflectee; @@ -157,6 +162,313 @@ void main() { transport.close(); }); + + test('consumeAeadPacket returns null for incomplete inputs', () { + final socket = _CaptureSSHSocket(); + final transport = SSHTransport(socket); + + setPrivate(transport, '_remoteVersion', 'SSH-2.0-test'); + setPrivate(transport, '_serverCipherType', SSHCipherType.aes128gcm); + setPrivate(transport, '_remoteCipherKey', Uint8List(16)); + setPrivate(transport, '_remoteIV', Uint8List(12)); + setSequenceValue(transport, '_remotePacketSN', 0); + + final resultNoHeader = reflect(transport).invoke( + privateSymbol('_consumeAeadPacket'), + [SSHCipherType.aes128gcm]).reflectee; + expect(resultNoHeader, isNull); + + final dynamic buffer = getPrivate(transport, '_buffer'); + buffer.add(Uint8List.fromList([0, 0, 0, 20, 1, 2, 3])); + + final resultPartial = reflect(transport).invoke( + privateSymbol('_consumeAeadPacket'), + [SSHCipherType.aes128gcm]).reflectee; + expect(resultPartial, isNull); + + transport.close(); + }); + + test('applyLocalKeys keeps AEAD mode without cipher/mac instances', () { + final socket = _CaptureSSHSocket(); + final transport = SSHTransport(socket); + + setPrivate(transport, '_kexType', SSHKexType.x25519); + setPrivate(transport, '_sharedSecret', BigInt.from(1)); + setPrivate(transport, '_exchangeHash', + Uint8List.fromList(List.filled(32, 1))); + setPrivate( + transport, '_sessionId', Uint8List.fromList(List.filled(32, 2))); + setPrivate(transport, '_clientCipherType', SSHCipherType.aes128gcm); + + reflect(transport).invoke(privateSymbol('_applyLocalKeys'), const []); + + final localKey = getPrivate(transport, '_localCipherKey'); + final localIv = getPrivate(transport, '_localIV'); + expect(localKey, isNotNull); + expect(localKey!.length, SSHCipherType.aes128gcm.keySize); + expect(localIv, isNotNull); + expect(localIv!.length, SSHCipherType.aes128gcm.ivSize); + expect(getPrivate(transport, '_encryptCipher'), isNull); + expect(getPrivate(transport, '_localMac'), isNull); + + transport.close(); + }); + + test('applyRemoteKeys keeps AEAD mode without cipher/mac instances', () { + final socket = _CaptureSSHSocket(); + final transport = SSHTransport(socket); + + setPrivate(transport, '_kexType', SSHKexType.x25519); + setPrivate(transport, '_sharedSecret', BigInt.from(1)); + setPrivate(transport, '_exchangeHash', + Uint8List.fromList(List.filled(32, 3))); + setPrivate( + transport, '_sessionId', Uint8List.fromList(List.filled(32, 4))); + setPrivate(transport, '_serverCipherType', SSHCipherType.aes128gcm); + + reflect(transport).invoke(privateSymbol('_applyRemoteKeys'), const []); + + final remoteKey = getPrivate(transport, '_remoteCipherKey'); + final remoteIv = getPrivate(transport, '_remoteIV'); + expect(remoteKey, isNotNull); + expect(remoteKey!.length, SSHCipherType.aes128gcm.keySize); + expect(remoteIv, isNotNull); + expect(remoteIv!.length, SSHCipherType.aes128gcm.ivSize); + expect(getPrivate(transport, '_decryptCipher'), isNull); + expect(getPrivate(transport, '_remoteMac'), isNull); + + transport.close(); + }); + + test('applyLocalKeys creates cipher and mac for non-AEAD algorithms', () { + final socket = _CaptureSSHSocket(); + final transport = SSHTransport(socket); + + setPrivate(transport, '_kexType', SSHKexType.x25519); + setPrivate(transport, '_sharedSecret', BigInt.from(5)); + setPrivate(transport, '_exchangeHash', + Uint8List.fromList(List.filled(32, 6))); + setPrivate( + transport, '_sessionId', Uint8List.fromList(List.filled(32, 7))); + setPrivate(transport, '_clientCipherType', SSHCipherType.aes128ctr); + setPrivate(transport, '_clientMacType', SSHMacType.hmacSha256); + + reflect(transport).invoke(privateSymbol('_applyLocalKeys'), const []); + + expect(getPrivate(transport, '_encryptCipher'), isNotNull); + expect(getPrivate(transport, '_localMac'), isNotNull); + + transport.close(); + }); + + test('applyRemoteKeys creates cipher and mac for non-AEAD algorithms', () { + final socket = _CaptureSSHSocket(); + final transport = SSHTransport(socket); + + setPrivate(transport, '_kexType', SSHKexType.x25519); + setPrivate(transport, '_sharedSecret', BigInt.from(8)); + setPrivate(transport, '_exchangeHash', + Uint8List.fromList(List.filled(32, 9))); + setPrivate(transport, '_sessionId', + Uint8List.fromList(List.filled(32, 10))); + setPrivate(transport, '_serverCipherType', SSHCipherType.aes128ctr); + setPrivate(transport, '_serverMacType', SSHMacType.hmacSha256); + + reflect(transport).invoke(privateSymbol('_applyRemoteKeys'), const []); + + expect(getPrivate(transport, '_decryptCipher'), isNotNull); + expect(getPrivate(transport, '_remoteMac'), isNotNull); + + transport.close(); + }); + + test('kexinit allows missing MAC when AEAD cipher is selected', () { + final socket = _CaptureSSHSocket(); + final transport = SSHTransport( + socket, + algorithms: const SSHAlgorithms( + cipher: [SSHCipherType.aes128gcm], + mac: [SSHMacType.hmacSha256], + ), + ); + + setPrivate(transport, '_kexInProgress', true); + setPrivate(transport, '_sentKexInit', true); + + final payload = SSH_Message_KexInit( + kexAlgorithms: [SSHKexType.x25519.name], + serverHostKeyAlgorithms: [SSHHostkeyType.ed25519.name], + encryptionClientToServer: [SSHCipherType.aes128gcm.name], + encryptionServerToClient: [SSHCipherType.aes128gcm.name], + macClientToServer: const ['missing-mac'], + macServerToClient: const ['missing-mac'], + compressionClientToServer: const ['none'], + compressionServerToClient: const ['none'], + firstKexPacketFollows: false, + ).encode(); + + expect( + () => reflect(transport) + .invoke(privateSymbol('_handleMessageKexInit'), [payload]), + returnsNormally, + ); + + transport.close(); + }); + + test('sendPacket buffers non-kex packets during key exchange', () { + final socket = _CaptureSSHSocket(); + final transport = SSHTransport(socket); + + setPrivate(transport, '_kexInProgress', true); + + // 94 is outside control/kex message ranges and should be buffered. + transport.sendPacket(Uint8List.fromList([94, 1, 2])); + + final pending = + getPrivate>(transport, '_rekeyPendingPackets'); + expect(pending, hasLength(1)); + expect(pending.first, Uint8List.fromList([94, 1, 2])); + + transport.close(); + }); + + test('applyLocalKeys throws when cipher type is missing', () { + final socket = _CaptureSSHSocket(); + final transport = SSHTransport(socket); + + expect( + () => reflect(transport) + .invoke(privateSymbol('_applyLocalKeys'), const []), + throwsA(isA()), + ); + + transport.close(); + }); + + test('applyRemoteKeys throws when cipher type is missing', () { + final socket = _CaptureSSHSocket(); + final transport = SSHTransport(socket); + + expect( + () => reflect(transport) + .invoke(privateSymbol('_applyRemoteKeys'), const []), + throwsA(isA()), + ); + + transport.close(); + }); + + test('applyLocalKeys throws when non-AEAD MAC type is missing', () { + final socket = _CaptureSSHSocket(); + final transport = SSHTransport(socket); + + setPrivate(transport, '_kexType', SSHKexType.x25519); + setPrivate(transport, '_sharedSecret', BigInt.from(11)); + setPrivate(transport, '_exchangeHash', + Uint8List.fromList(List.filled(32, 12))); + setPrivate(transport, '_sessionId', + Uint8List.fromList(List.filled(32, 13))); + setPrivate(transport, '_clientCipherType', SSHCipherType.aes128ctr); + + expect( + () => reflect(transport) + .invoke(privateSymbol('_applyLocalKeys'), const []), + throwsA(isA()), + ); + + transport.close(); + }); + + test('applyRemoteKeys throws when non-AEAD MAC type is missing', () { + final socket = _CaptureSSHSocket(); + final transport = SSHTransport(socket); + + setPrivate(transport, '_kexType', SSHKexType.x25519); + setPrivate(transport, '_sharedSecret', BigInt.from(14)); + setPrivate(transport, '_exchangeHash', + Uint8List.fromList(List.filled(32, 15))); + setPrivate(transport, '_sessionId', + Uint8List.fromList(List.filled(32, 16))); + setPrivate(transport, '_serverCipherType', SSHCipherType.aes128ctr); + + expect( + () => reflect(transport) + .invoke(privateSymbol('_applyRemoteKeys'), const []), + throwsA(isA()), + ); + + transport.close(); + }); + + test('kexinit requires client MAC when non-AEAD cipher is selected', () { + final socket = _CaptureSSHSocket(); + final transport = SSHTransport( + socket, + algorithms: const SSHAlgorithms( + cipher: [SSHCipherType.aes128ctr], + mac: [SSHMacType.hmacSha256], + ), + ); + + setPrivate(transport, '_kexInProgress', true); + setPrivate(transport, '_sentKexInit', true); + + final payload = SSH_Message_KexInit( + kexAlgorithms: [SSHKexType.x25519.name], + serverHostKeyAlgorithms: [SSHHostkeyType.ed25519.name], + encryptionClientToServer: [SSHCipherType.aes128ctr.name], + encryptionServerToClient: [SSHCipherType.aes128ctr.name], + macClientToServer: const ['missing-mac'], + macServerToClient: [SSHMacType.hmacSha256.name], + compressionClientToServer: const ['none'], + compressionServerToClient: const ['none'], + firstKexPacketFollows: false, + ).encode(); + + expect( + () => reflect(transport) + .invoke(privateSymbol('_handleMessageKexInit'), [payload]), + throwsA(isA()), + ); + + transport.close(); + }); + + test('kexinit requires server MAC when non-AEAD cipher is selected', () { + final socket = _CaptureSSHSocket(); + final transport = SSHTransport( + socket, + algorithms: const SSHAlgorithms( + cipher: [SSHCipherType.aes128ctr], + mac: [SSHMacType.hmacSha256], + ), + ); + + setPrivate(transport, '_kexInProgress', true); + setPrivate(transport, '_sentKexInit', true); + + final payload = SSH_Message_KexInit( + kexAlgorithms: [SSHKexType.x25519.name], + serverHostKeyAlgorithms: [SSHHostkeyType.ed25519.name], + encryptionClientToServer: [SSHCipherType.aes128ctr.name], + encryptionServerToClient: [SSHCipherType.aes128ctr.name], + macClientToServer: [SSHMacType.hmacSha256.name], + macServerToClient: const ['missing-mac'], + compressionClientToServer: const ['none'], + compressionServerToClient: const ['none'], + firstKexPacketFollows: false, + ).encode(); + + expect( + () => reflect(transport) + .invoke(privateSymbol('_handleMessageKexInit'), [payload]), + throwsA(isA()), + ); + + transport.close(); + }); }); } From 6977b3c3f5740844db0252aa4449cbaa837164b8 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Sat, 28 Mar 2026 09:22:25 +0100 Subject: [PATCH 16/24] docs: add note about SSHSocket.connect() limitations for Flutter Web and Dart Web --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index ce38b86..2586545 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ void main() async { } ``` +> Note: `SSHSocket.connect()` uses native TCP sockets (`dart:io`) and is not +> available on Flutter Web / Dart Web. See [Web support](#web-support) below +> for browser-compatible transport options. + > `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. ### Web support From d41840ab98cba545bc2e0ccde127df9e93486820 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Sat, 28 Mar 2026 09:44:15 +0100 Subject: [PATCH 17/24] chore: update release date for version 2.17.0 in CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91a1842..8ad1c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## [2.17.0] - yyyy-mm-dd +## [2.17.0] - 2026-03-28 - Improved Web/WASM compatibility by updating `SSHSocket` conditional imports so web runtimes consistently use the web socket shim and avoid incorrect native socket selection [#88]. Thanks [@vicajilau]. - Added local dynamic forwarding (`SSHClient.forwardDynamic`) with SOCKS5 `NO AUTH` + `CONNECT`, including configurable handshake/connect timeouts and connection limits. - Added AES-GCM (`aes128-gcm@openssh.com`, `aes256-gcm@openssh.com`) AEAD groundwork in transport and cipher negotiation; currently opt-in (not enabled by default yet). `chacha20-poly1305@openssh.com` remains pending [#26]. Thanks [@vicajilau]. From 4db9a55445b4c933eaf0f52ac8e485f836bdbe69 Mon Sep 17 00:00:00 2001 From: GT610 Date: Mon, 30 Mar 2026 21:19:01 +0800 Subject: [PATCH 18/24] fix(transport): Fixed AEAD integrity checks and improved random padding refactor: Renamed MAC protection checks to integrity protection checks test: Updated AEAD tests to use new variable names fix: Fixed the condition for the dynamic forwarding connection limit docs: Updated unit test comments to clarify their purpose chore: Removed duplicate entries from the CHANGELOG --- CHANGELOG.md | 1 - example/forward_dynamic.dart | 28 +++++++++++-------- lib/src/algorithm/ssh_cipher_type.dart | 8 ++++++ lib/src/dynamic_forward_io.dart | 14 +++++----- lib/src/ssh_client.dart | 2 +- lib/src/ssh_transport.dart | 17 +++++++++-- test/src/ssh_client_forward_dynamic_test.dart | 6 +++- test/src/ssh_transport_aead_test.dart | 12 ++++---- 8 files changed, 58 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c81fe08..a077367 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -201,7 +201,6 @@ [#145]: https://github.com/TerminalStudio/dartssh2/pull/145 [#141]: https://github.com/TerminalStudio/dartssh2/pull/141 [#140]: https://github.com/TerminalStudio/dartssh2/pull/140 -[#145]: https://github.com/TerminalStudio/dartssh2/pull/145 [#153]: https://github.com/TerminalStudio/dartssh2/pull/153 [#102]: https://github.com/TerminalStudio/dartssh2/issues/102 [#99]: https://github.com/TerminalStudio/dartssh2/issues/99 diff --git a/example/forward_dynamic.dart b/example/forward_dynamic.dart index 41bc860..85780ff 100644 --- a/example/forward_dynamic.dart +++ b/example/forward_dynamic.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:dartssh2/dartssh2.dart'; @@ -16,20 +17,9 @@ Future main() async { onPasswordRequest: () => password, ); - await client.authenticated; - final dynamicForward = await client.forwardDynamic( bindHost: '127.0.0.1', bindPort: 1080, - options: const SSHDynamicForwardOptions( - handshakeTimeout: Duration(seconds: 10), - connectTimeout: Duration(seconds: 15), - maxConnections: 64, - ), - filter: (targetHost, targetPort) { - // Allow only web ports in this sample. - return targetPort == 80 || targetPort == 443; - }, ); print( @@ -37,7 +27,21 @@ Future main() async { ); print('Press Ctrl+C to stop.'); - ProcessSignal.sigint.watch().listen((_) async { + StreamSubscription? sigintSub; + StreamSubscription? sigtermSub; + + sigintSub = ProcessSignal.sigint.watch().listen((_) async { + await sigintSub?.cancel(); + await sigtermSub?.cancel(); + await dynamicForward.close(); + client.close(); + await client.done; + exit(0); + }); + + sigtermSub = ProcessSignal.sigterm.watch().listen((_) async { + await sigintSub?.cancel(); + await sigtermSub?.cancel(); await dynamicForward.close(); client.close(); await client.done; diff --git a/lib/src/algorithm/ssh_cipher_type.dart b/lib/src/algorithm/ssh_cipher_type.dart index 846bf1b..5428d6e 100644 --- a/lib/src/algorithm/ssh_cipher_type.dart +++ b/lib/src/algorithm/ssh_cipher_type.dart @@ -159,6 +159,14 @@ class SSHCipherType extends SSHAlgorithm { throw ArgumentError.value(key, 'key', 'Key must be $keySize bytes long'); } + if (nonce.length != ivSize) { + throw ArgumentError.value( + nonce, + 'nonce', + 'Nonce must be $ivSize bytes long', + ); + } + final factory = cipherFactory; if (factory == null) { throw StateError('No AEAD cipher factory configured for $name'); diff --git a/lib/src/dynamic_forward_io.dart b/lib/src/dynamic_forward_io.dart index 191b334..a7d62c4 100644 --- a/lib/src/dynamic_forward_io.dart +++ b/lib/src/dynamic_forward_io.dart @@ -64,7 +64,7 @@ class _SSHDynamicForwardImpl implements SSHDynamicForward { client, options: options, filter: filter, - canOpenTunnel: () => _connections.length <= options.maxConnections, + canOpenTunnel: () => _connections.length < options.maxConnections, dial: dial, onClosed: () => _connections.remove(connection), ); @@ -117,17 +117,17 @@ class _SocksConnection { _SocksState _state = _SocksState.greeting; void start() { - _handshakeTimer = Timer(options.handshakeTimeout, () async { - _sendReply(_SocksReply.ttlExpired); - await close(); - }); - _clientSub = _client.listen( _onClientData, onDone: close, onError: (_, __) => close(), cancelOnError: true, ); + + _handshakeTimer = Timer(options.handshakeTimeout, () async { + _sendReply(_SocksReply.ttlExpired); + await close(); + }); } Future close() async { @@ -288,7 +288,7 @@ class _SocksConnection { if (atyp == 0x03) { final length = request[4]; final bytes = request.sublist(5, 5 + length); - return utf8.decode(bytes); + return utf8.decode(bytes, allowMalformed: true); } final raw = request.sublist(4, 20); diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index 5f022b6..91360be 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -1732,7 +1732,7 @@ extension on SSHClient { /// Check if the transport layer provides MAC protection bool get _hasMacProtection { - return _transport.hasMacProtection; + return _transport.hasIntegrityProtection; } } diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index f92bd02..148cb3c 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -206,6 +206,8 @@ class SSHTransport { final _remotePacketSN = SSHPacketSN.fromZero(); + final _paddingRandom = Random.secure(); + /// Whether a key exchange is currently in progress (initial or re-key). bool _kexInProgress = false; @@ -394,8 +396,7 @@ class SSHTransport { ..setRange(1, 1 + data.length, data); for (var i = 0; i < paddingLength; i++) { - plaintext[1 + data.length + i] = - (DateTime.now().microsecondsSinceEpoch + i) & 0xff; + plaintext[1 + data.length + i] = _paddingRandom.nextInt(256); } final encrypted = _processAead( @@ -1706,7 +1707,19 @@ class SSHTransport { } /// Returns true if both MACs are initialized (MAC protection is provided). + /// + /// This only checks if [_localMac] and [_remoteMac] are non-null. + /// For AEAD ciphers, these will be null even though integrity is provided. + @Deprecated('Use hasIntegrityProtection instead') bool get hasMacProtection { + return _localMac != null && _remoteMac != null; + } + + /// Returns true if integrity protection is provided. + /// + /// This is true when AEAD ciphers (GCM, ChaCha20-Poly1305) are used, + /// or when traditional MAC algorithms are in use. + bool get hasIntegrityProtection { final usingAead = (_clientCipherType?.isAead == true) || (_serverCipherType?.isAead == true); if (usingAead) return true; diff --git a/test/src/ssh_client_forward_dynamic_test.dart b/test/src/ssh_client_forward_dynamic_test.dart index 7832e9b..1df5076 100644 --- a/test/src/ssh_client_forward_dynamic_test.dart +++ b/test/src/ssh_client_forward_dynamic_test.dart @@ -14,7 +14,11 @@ void main() { keepAliveInterval: null, ); - // Simulate server auth success so forwardDynamic can proceed. + // This is an intentional unit-test shortcut that bypasses the full SSH + // handshake. It simulates receiving SSH_Message_Userauth_Success and + // injects it via client.handlePacket() to drive forwardDynamic's behavior + // of waiting for authentication. This test only verifies that + // forwardDynamic properly waits for auth to complete before proceeding. scheduleMicrotask(() { client.handlePacket(SSH_Message_Userauth_Success().encode()); }); diff --git a/test/src/ssh_transport_aead_test.dart b/test/src/ssh_transport_aead_test.dart index c5e2698..7246460 100644 --- a/test/src/ssh_transport_aead_test.dart +++ b/test/src/ssh_transport_aead_test.dart @@ -74,8 +74,8 @@ void main() { setPrivate(receiver, '_remoteVersion', 'SSH-2.0-test'); setPrivate(receiver, '_serverCipherType', SSHCipherType.aes128gcm); - setPrivate(receiver, '_remoteCipherKey', key); - setPrivate(receiver, '_remoteIV', iv); + setPrivate(receiver, '_remoteAeadKey', key); + setPrivate(receiver, '_remoteAeadFixedNonce', iv); setSequenceValue(receiver, '_remotePacketSN', 0); receiverSocket.addIncomingBytes(encryptedPacket); @@ -127,8 +127,8 @@ void main() { setPrivate(receiver, '_remoteVersion', 'SSH-2.0-test'); setPrivate(receiver, '_serverCipherType', SSHCipherType.aes128gcm); - setPrivate(receiver, '_remoteCipherKey', key); - setPrivate(receiver, '_remoteIV', iv); + setPrivate(receiver, '_remoteAeadKey', key); + setPrivate(receiver, '_remoteAeadFixedNonce', iv); setSequenceValue(receiver, '_remotePacketSN', 0); receiverSocket.addIncomingBytes(tampered); @@ -169,8 +169,8 @@ void main() { setPrivate(transport, '_remoteVersion', 'SSH-2.0-test'); setPrivate(transport, '_serverCipherType', SSHCipherType.aes128gcm); - setPrivate(transport, '_remoteCipherKey', Uint8List(16)); - setPrivate(transport, '_remoteIV', Uint8List(12)); + setPrivate(transport, '_remoteAeadKey', Uint8List(16)); + setPrivate(transport, '_remoteAeadFixedNonce', Uint8List(12)); setSequenceValue(transport, '_remotePacketSN', 0); final resultNoHeader = reflect(transport).invoke( From f13cb8fd826f81e7f4768ba3ef8658285aa57910 Mon Sep 17 00:00:00 2001 From: GT610 Date: Mon, 30 Mar 2026 21:42:58 +0800 Subject: [PATCH 19/24] fix: Fixed an issue with resource release when a dynamic forwarding connection is closed Fixed an issue in the _SocksConnection class where remote resources were not properly released when the connection was closed Corrected an incorrect assignment of the client and server IV derivation keys in SSHTransport Optimized signal handling and closure logic in the sample code --- CHANGELOG.md | 2 +- example/forward_dynamic.dart | 22 +++++++++++----------- lib/src/dynamic_forward_io.dart | 6 ++++++ lib/src/ssh_transport.dart | 6 +++++- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a077367..b9ee4fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [2.17.0] - 2026-03-28 - Improved Web/WASM compatibility by updating `SSHSocket` conditional imports so web runtimes consistently use the web socket shim and avoid incorrect native socket selection [#88]. Thanks [@vicajilau]. -- Added local dynamic forwarding (`SSHClient.forwardDynamic`) with SOCKS5 `NO AUTH` + `CONNECT`, including configurable handshake/connect timeouts and connection limits. +- Added local dynamic forwarding (`SSHClient.forwardDynamic`) with SOCKS5 `NO AUTH` + `CONNECT`, including configurable handshake/connect timeouts and connection limits [#153]. - Added AES-GCM (`aes128-gcm@openssh.com`, `aes256-gcm@openssh.com`) AEAD groundwork in transport and cipher negotiation; currently opt-in (not enabled by default yet). `chacha20-poly1305@openssh.com` remains pending [#26]. Thanks [@vicajilau]. ## [2.16.0] - 2026-03-24 diff --git a/example/forward_dynamic.dart b/example/forward_dynamic.dart index 85780ff..9bdeb94 100644 --- a/example/forward_dynamic.dart +++ b/example/forward_dynamic.dart @@ -15,6 +15,12 @@ Future main() async { socket, username: username, onPasswordRequest: () => password, + onVerifyHostKey: (host, verifier) { + // WARNING: Accepting any host key for demonstration purposes only. + // In production, verify the host key against a known trusted value. + print('WARNING: Host key verification disabled for testing.'); + return true; + }, ); final dynamicForward = await client.forwardDynamic( @@ -30,23 +36,17 @@ Future main() async { StreamSubscription? sigintSub; StreamSubscription? sigtermSub; - sigintSub = ProcessSignal.sigint.watch().listen((_) async { + Future shutdown() async { await sigintSub?.cancel(); await sigtermSub?.cancel(); await dynamicForward.close(); client.close(); await client.done; exit(0); - }); + } - sigtermSub = ProcessSignal.sigterm.watch().listen((_) async { - await sigintSub?.cancel(); - await sigtermSub?.cancel(); - await dynamicForward.close(); - client.close(); - await client.done; - exit(0); - }); + sigintSub = ProcessSignal.sigint.watch().listen((_) => shutdown()); + sigtermSub = ProcessSignal.sigterm.watch().listen((_) => shutdown()); - await Future.delayed(const Duration(days: 365)); + await client.done; } diff --git a/lib/src/dynamic_forward_io.dart b/lib/src/dynamic_forward_io.dart index a7d62c4..c94df2d 100644 --- a/lib/src/dynamic_forward_io.dart +++ b/lib/src/dynamic_forward_io.dart @@ -194,6 +194,12 @@ class _SocksConnection { return; } + if (_closed) { + _remote?.destroy(); + _remote = null; + return; + } + _remoteSub = _remote!.stream.listen( _client.add, onDone: close, diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index 148cb3c..2ca58f8 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -1037,11 +1037,13 @@ class SSHTransport { cipherType.keySize, ); final iv = _deriveKey( - isClient ? SSHDeriveKeyType.clientIV : SSHDeriveKeyType.serverIV, + isClient ? SSHDeriveKeyType.clientIV : SSHDeriveKeyType.clientIV, cipherType.ivSize, ); _localAeadKey = key; _localAeadFixedNonce = Uint8List.sublistView(iv, 0, 12); + _localCipherKey = key; + _localIV = iv; } _encryptCipher = null; _localMac = null; // AEAD provides integrity @@ -1099,6 +1101,8 @@ class SSHTransport { ); _remoteAeadKey = key; _remoteAeadFixedNonce = Uint8List.sublistView(iv, 0, 12); + _remoteCipherKey = key; + _remoteIV = iv; } _decryptCipher = null; _remoteMac = null; // AEAD provides integrity From 52f8e3e97a4b94aad33324683d26bdb362e58670 Mon Sep 17 00:00:00 2001 From: GT610 Date: Mon, 30 Mar 2026 23:19:21 +0800 Subject: [PATCH 20/24] fix: Fixed issues with SSH dynamic forwarding and connection closure Fixed an issue with resource release when a connection is closed during dynamic forwarding, ensuring that both the remote connection and the client are properly closed Fixed logic for IV derivation errors and integrity checks during SSH transmission Added a connection closure status flag to prevent duplicate closures Optimized the key renegotiation mechanism to trigger based on data volume --- example/forward_dynamic.dart | 7 +++++-- lib/src/dynamic_forward_io.dart | 17 ++++++++++++----- lib/src/ssh_transport.dart | 22 ++++++++++++++++------ 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/example/forward_dynamic.dart b/example/forward_dynamic.dart index 9bdeb94..93115ce 100644 --- a/example/forward_dynamic.dart +++ b/example/forward_dynamic.dart @@ -16,8 +16,6 @@ Future main() async { username: username, onPasswordRequest: () => password, onVerifyHostKey: (host, verifier) { - // WARNING: Accepting any host key for demonstration purposes only. - // In production, verify the host key against a known trusted value. print('WARNING: Host key verification disabled for testing.'); return true; }, @@ -35,8 +33,12 @@ Future main() async { StreamSubscription? sigintSub; StreamSubscription? sigtermSub; + var isShuttingDown = false; Future shutdown() async { + if (isShuttingDown) return; + isShuttingDown = true; + await sigintSub?.cancel(); await sigtermSub?.cancel(); await dynamicForward.close(); @@ -49,4 +51,5 @@ Future main() async { sigtermSub = ProcessSignal.sigterm.watch().listen((_) => shutdown()); await client.done; + await shutdown(); } diff --git a/lib/src/dynamic_forward_io.dart b/lib/src/dynamic_forward_io.dart index c94df2d..9a8ce57 100644 --- a/lib/src/dynamic_forward_io.dart +++ b/lib/src/dynamic_forward_io.dart @@ -69,8 +69,8 @@ class _SSHDynamicForwardImpl implements SSHDynamicForward { onClosed: () => _connections.remove(connection), ); - _connections.add(connection); connection.start(); + _connections.add(connection); } @override @@ -119,7 +119,10 @@ class _SocksConnection { void start() { _clientSub = _client.listen( _onClientData, - onDone: close, + onDone: () { + _remote?.sink.close(); + _client.destroy(); + }, onError: (_, __) => close(), cancelOnError: true, ); @@ -184,6 +187,9 @@ class _SocksConnection { return; } + _handshakeTimer?.cancel(); + _handshakeTimer = null; + try { _remote = await dial(target.host, target.port).timeout( options.connectTimeout, @@ -202,14 +208,15 @@ class _SocksConnection { _remoteSub = _remote!.stream.listen( _client.add, - onDone: close, + onDone: () { + _client.destroy(); + _remote?.destroy(); + }, onError: (_, __) => close(), cancelOnError: true, ); _sendReply(_SocksReply.succeeded); - _handshakeTimer?.cancel(); - _handshakeTimer = null; _state = _SocksState.streaming; final pending = _buffer.takeAll(); diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index 2ca58f8..d15da44 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -412,7 +412,15 @@ class SSHTransport { ..add(aad) ..add(encrypted); - socket.sink.add(buffer.takeBytes()); + final bytes = buffer.takeBytes(); + _bytesSent += bytes.length; + socket.sink.add(bytes); + + if (_bytesSent >= _dataLimitForRekey) { + _reKeyTimer?.cancel(); + _sendKexInit(); + _bytesSent = 0; + } } int _alignedPaddingLength(int payloadLength, int align) { @@ -1037,7 +1045,7 @@ class SSHTransport { cipherType.keySize, ); final iv = _deriveKey( - isClient ? SSHDeriveKeyType.clientIV : SSHDeriveKeyType.clientIV, + isClient ? SSHDeriveKeyType.clientIV : SSHDeriveKeyType.serverIV, cipherType.ivSize, ); _localAeadKey = key; @@ -1721,11 +1729,13 @@ class SSHTransport { /// Returns true if integrity protection is provided. /// - /// This is true when AEAD ciphers (GCM, ChaCha20-Poly1305) are used, - /// or when traditional MAC algorithms are in use. + /// This is true when AEAD keys are initialized (GCM, ChaCha20-Poly1305), + /// or when traditional MAC algorithms are initialized. bool get hasIntegrityProtection { - final usingAead = (_clientCipherType?.isAead == true) || - (_serverCipherType?.isAead == true); + final usingAead = (_localAeadKey != null || + _localChaChaEncKey != null || + _remoteAeadKey != null || + _remoteChaChaEncKey != null); if (usingAead) return true; return _localMac != null && _remoteMac != null; } From f846249bb4bd5c83fcb5648a6c869eed63fc941c Mon Sep 17 00:00:00 2001 From: GT610 Date: Tue, 31 Mar 2026 10:45:28 +0800 Subject: [PATCH 21/24] refactor: Simplify connection closure logic and optimize SSH transport handling - In the _SocksConnection class, extract duplicate closure logic into a dedicated `close` method - In the SSHTransport class, refactor the AEAD encryption logic to support the ChaCha20-Poly1305 algorithm - Move the re-key trigger logic to the AEAD processing branch --- lib/src/dynamic_forward_io.dart | 10 ++------ lib/src/ssh_transport.dart | 43 +++++++++++++++++++-------------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/lib/src/dynamic_forward_io.dart b/lib/src/dynamic_forward_io.dart index 9a8ce57..fba7d7c 100644 --- a/lib/src/dynamic_forward_io.dart +++ b/lib/src/dynamic_forward_io.dart @@ -119,10 +119,7 @@ class _SocksConnection { void start() { _clientSub = _client.listen( _onClientData, - onDone: () { - _remote?.sink.close(); - _client.destroy(); - }, + onDone: () => close(), onError: (_, __) => close(), cancelOnError: true, ); @@ -208,10 +205,7 @@ class _SocksConnection { _remoteSub = _remote!.stream.listen( _client.add, - onDone: () { - _client.destroy(); - _remote?.destroy(); - }, + onDone: () => close(), onError: (_, __) => close(), cancelOnError: true, ); diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index d15da44..606fc1c 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -239,24 +239,37 @@ class SSHTransport { final macType = isClient ? clientMacType : serverMacType; final localCipherType = isClient ? _clientCipherType : _serverCipherType; - if (localCipherType != null && - localCipherType.isAead && - _localCipherKey != null && - _localIV != null) { - _sendAeadPacket(data, localCipherType); + final usingAead = localCipherType?.isAead ?? false; + final isChaCha = localCipherType?.name == 'chacha20-poly1305@openssh.com'; + final aeadReady = isChaCha + ? (_localChaChaEncKey != null && _localChaChaLenKey != null) + : (localCipherType != null && + _localCipherKey != null && + _localIV != null); + + if (usingAead && aeadReady) { + if (isChaCha) { + final encKey = _localChaChaEncKey!; + final lenKey = _localChaChaLenKey!; + final packetAlign = max(SSHPacket.minAlign, 8); + final packet = SSHPacket.pack(data, align: packetAlign); + final out = _encryptChaChaOpenSSH(packet, encKey, lenKey, _localPacketSN.value); + _bytesSent += packet.length + localCipherType!.aeadTagSize; + socket.sink.add(out); + } else { + _sendAeadPacket(data, localCipherType!); + } _localPacketSN.increase(); + if (_bytesSent >= _dataLimitForRekey) { + _reKeyTimer?.cancel(); + _sendKexInit(); + _bytesSent = 0; + } return; } final isEtm = _encryptCipher != null && macType != null && macType.isEtm; - final ctLocal = isClient ? _clientCipherType : _serverCipherType; - final usingAead = ctLocal?.isAead ?? false; - final isChaCha = ctLocal?.name == 'chacha20-poly1305@openssh.com'; - final aeadReady = isChaCha - ? (_localChaChaEncKey != null && _localChaChaLenKey != null) - : (_localAeadKey != null && _localAeadFixedNonce != null); - if (isEtm) { final blockSize = _encryptCipher!.blockSize; @@ -415,12 +428,6 @@ class SSHTransport { final bytes = buffer.takeBytes(); _bytesSent += bytes.length; socket.sink.add(bytes); - - if (_bytesSent >= _dataLimitForRekey) { - _reKeyTimer?.cancel(); - _sendKexInit(); - _bytesSent = 0; - } } int _alignedPaddingLength(int payloadLength, int align) { From 9bd5f6e8518731e3a78b4eec056d045c5abc8d20 Mon Sep 17 00:00:00 2001 From: GT610 Date: Tue, 31 Mar 2026 11:04:47 +0800 Subject: [PATCH 22/24] refactor: Optimize code formatting and EOF handling logic - Adjust code formatting and improve line breaks in long lines to enhance readability - Extract EOF handling logic into separate methods: _handleClientEOF and _handleRemoteEOF - Add a maximum handshake size limit to _ByteBuffer --- lib/src/dynamic_forward_io.dart | 29 ++++++++++++++++++-- lib/src/sftp/sftp_client.dart | 7 +++-- lib/src/ssh_agent.dart | 4 +-- lib/src/ssh_key_pair.dart | 3 +- lib/src/ssh_transport.dart | 3 +- test/src/algorithm/ssh_cipher_type_test.dart | 3 +- 6 files changed, 39 insertions(+), 10 deletions(-) diff --git a/lib/src/dynamic_forward_io.dart b/lib/src/dynamic_forward_io.dart index fba7d7c..6e2d3a4 100644 --- a/lib/src/dynamic_forward_io.dart +++ b/lib/src/dynamic_forward_io.dart @@ -119,7 +119,7 @@ class _SocksConnection { void start() { _clientSub = _client.listen( _onClientData, - onDone: () => close(), + onDone: _handleClientEOF, onError: (_, __) => close(), cancelOnError: true, ); @@ -130,6 +130,25 @@ class _SocksConnection { }); } + void _handleClientEOF() { + if (_state == _SocksState.streaming) { + _remote?.sink.close(); + _clientSub?.cancel(); + } else { + close(); + } + } + + void _handleRemoteEOF() { + if (_state == _SocksState.streaming) { + _client.destroy(); + _remoteSub?.cancel(); + onClosed(); + } else { + close(); + } + } + Future close() async { if (_closed) return; _closed = true; @@ -205,7 +224,7 @@ class _SocksConnection { _remoteSub = _remote!.stream.listen( _client.add, - onDone: () => close(), + onDone: _handleRemoteEOF, onError: (_, __) => close(), cancelOnError: true, ); @@ -323,12 +342,18 @@ class _SocksConnection { } class _ByteBuffer { + static const kMaxHandshakeSize = 32768; + final _data = []; int _offset = 0; int get length => _data.length - _offset; void add(List chunk) { + if (length + chunk.length > kMaxHandshakeSize) { + throw StateError( + 'Handshake buffer overflow: $length + ${chunk.length} > $kMaxHandshakeSize'); + } _data.addAll(chunk); } diff --git a/lib/src/sftp/sftp_client.dart b/lib/src/sftp/sftp_client.dart index 04eec5a..31e357f 100644 --- a/lib/src/sftp/sftp_client.dart +++ b/lib/src/sftp/sftp_client.dart @@ -641,10 +641,11 @@ class SftpFile { var bytesRead = 0; while (bytesRead < length) { - while ( - reservedOffset < endOffset && pendingReads.length < maxPendingRequests) { + while (reservedOffset < endOffset && + pendingReads.length < maxPendingRequests) { final requestLength = min(chunkSize, endOffset - reservedOffset); - pendingReads.add((reservedOffset, _readChunk(requestLength, reservedOffset))); + pendingReads + .add((reservedOffset, _readChunk(requestLength, reservedOffset))); reservedOffset += requestLength; } diff --git a/lib/src/ssh_agent.dart b/lib/src/ssh_agent.dart index 40ced49..3403c3a 100644 --- a/lib/src/ssh_agent.dart +++ b/lib/src/ssh_agent.dart @@ -200,8 +200,8 @@ class SSHAgentChannel { 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'); + printDebug + ?.call('SSH agent: invalid frame length $length, closing channel'); _channel.destroy(); _buffer = Uint8List(0); return; diff --git a/lib/src/ssh_key_pair.dart b/lib/src/ssh_key_pair.dart index 739d5ef..a09e540 100644 --- a/lib/src/ssh_key_pair.dart +++ b/lib/src/ssh_key_pair.dart @@ -798,7 +798,8 @@ class EcKeyPair { } } - curveId ??= _inferCurveId(publicPoint?.length ?? 0, privateKeyOctets.length); + curveId ??= + _inferCurveId(publicPoint?.length ?? 0, privateKeyOctets.length); if (curveId == null) { throw UnsupportedError('Unsupported EC PRIVATE KEY curve'); } diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index 606fc1c..1e292d2 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -253,7 +253,8 @@ class SSHTransport { final lenKey = _localChaChaLenKey!; final packetAlign = max(SSHPacket.minAlign, 8); final packet = SSHPacket.pack(data, align: packetAlign); - final out = _encryptChaChaOpenSSH(packet, encKey, lenKey, _localPacketSN.value); + final out = + _encryptChaChaOpenSSH(packet, encKey, lenKey, _localPacketSN.value); _bytesSent += packet.length + localCipherType!.aeadTagSize; socket.sink.add(out); } else { diff --git a/test/src/algorithm/ssh_cipher_type_test.dart b/test/src/algorithm/ssh_cipher_type_test.dart index e2077f1..900e2f1 100644 --- a/test/src/algorithm/ssh_cipher_type_test.dart +++ b/test/src/algorithm/ssh_cipher_type_test.dart @@ -230,7 +230,8 @@ void testAEADCipher(SSHCipherType type) { if (type.name.contains('gcm')) { // GCM supports one-shot process returning ciphertext+tag encryptedWithTag = encrypter.process(plainText); - expect(encryptedWithTag.length, equals(plainText.length + type.aeadTagSize)); + expect( + encryptedWithTag.length, equals(plainText.length + type.aeadTagSize)); } else { // ChaCha20-Poly1305 requires doFinal to append tag final outLen = encrypter.getOutputSize(plainText.length); From 20eb905eb15b342c5e7a63a34d5f3bf2e970b55f Mon Sep 17 00:00:00 2001 From: GT610 Date: Tue, 31 Mar 2026 11:42:37 +0800 Subject: [PATCH 23/24] refactor(SSHTransport): Clean up unused AEAD encryption-related code Remove the _localAeadFixedNonce variable and related AEAD encryption logic, which are no longer in use Simplify the conditional logic in the hasIntegrityProtection method --- lib/src/dynamic_forward_io.dart | 1 - lib/src/ssh_transport.dart | 88 ++++++--------------------------- 2 files changed, 15 insertions(+), 74 deletions(-) diff --git a/lib/src/dynamic_forward_io.dart b/lib/src/dynamic_forward_io.dart index 6e2d3a4..d4f2182 100644 --- a/lib/src/dynamic_forward_io.dart +++ b/lib/src/dynamic_forward_io.dart @@ -143,7 +143,6 @@ class _SocksConnection { if (_state == _SocksState.streaming) { _client.destroy(); _remoteSub?.cancel(); - onClosed(); } else { close(); } diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index 1e292d2..517f6a4 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -175,8 +175,7 @@ class SSHTransport { // AEAD (GCM / ChaCha20-Poly1305) keys and nonces (per direction) Uint8List? _localAeadKey; // key for data we send - Uint8List? - _localAeadFixedNonce; // 12-byte fixed part of nonce for data we send +// 12-byte fixed part of nonce for data we send Uint8List? _remoteAeadKey; // key for data we receive Uint8List? _remoteAeadFixedNonce; // 12-byte fixed part of nonce for data we receive @@ -313,54 +312,6 @@ class SSHTransport { packetLengthBytes.length + encryptedPayload.length + macBytes.length; socket.sink.add(buffer.takeBytes()); - } else if (usingAead && aeadReady) { - final packetAlign = max(SSHPacket.minAlign, 8); - final packet = SSHPacket.pack(data, align: packetAlign); - - final cipherType = isClient ? _clientCipherType! : _serverCipherType!; - if (cipherType.name == 'chacha20-poly1305@openssh.com') { - final encKey = _localChaChaEncKey; - final lenKey = _localChaChaLenKey; - if (encKey == null || lenKey == null) { - throw StateError('ChaCha20-Poly1305 keys not initialized'); - } - final out = - _encryptChaChaOpenSSH(packet, encKey, lenKey, _localPacketSN.value); - _bytesSent += packet.length + cipherType.aeadTagSize; - socket.sink.add(out); - } else { - final key = _localAeadKey!; - final fixedNonce = _localAeadFixedNonce!; - - final lenBytes = Uint8List.sublistView(packet, 0, 4); - final body = Uint8List.sublistView(packet, 4); - - final nonce = _composeAeadNonce(fixedNonce, _localPacketSN.value); - - final aead = cipherType.createAEADCipher( - key, - nonce, - forEncryption: true, - aad: lenBytes, - ); - - final outLen = aead.getOutputSize(body.length); - var encryptedWithTag = Uint8List(outLen); - var written = - aead.processBytes(body, 0, body.length, encryptedWithTag, 0); - written += aead.doFinal(encryptedWithTag, written); - if (written != encryptedWithTag.length) { - encryptedWithTag = - Uint8List.sublistView(encryptedWithTag, 0, written); - } - - _bytesSent += packet.length + cipherType.aeadTagSize; - - final out = BytesBuilder(copy: false) - ..add(lenBytes) - ..add(encryptedWithTag); - socket.sink.add(out.takeBytes()); - } } else if (_encryptCipher == null) { final packet = SSHPacket.pack(data, align: SSHPacket.minAlign); _bytesSent += packet.length; @@ -1045,7 +996,6 @@ class SSHTransport { _localChaChaLenKey = lenKey; _localChaChaEncKey = encKey; _localAeadKey = null; - _localAeadFixedNonce = null; } else { // AEAD: derive key and fixed nonce (12 bytes) for sender direction final key = _deriveKey( @@ -1057,7 +1007,6 @@ class SSHTransport { cipherType.ivSize, ); _localAeadKey = key; - _localAeadFixedNonce = Uint8List.sublistView(iv, 0, 12); _localCipherKey = key; _localIV = iv; } @@ -1087,6 +1036,10 @@ class SSHTransport { ); _localMac = macType.createMac(macKey); + + _localAeadKey = null; + _localCipherKey = null; + _localIV = null; } } @@ -1145,6 +1098,10 @@ class SSHTransport { macType.keySize, ); _remoteMac = macType.createMac(macKey); + + _remoteAeadKey = null; + _remoteCipherKey = null; + _remoteIV = null; } } @@ -1737,31 +1694,16 @@ class SSHTransport { /// Returns true if integrity protection is provided. /// - /// This is true when AEAD keys are initialized (GCM, ChaCha20-Poly1305), - /// or when traditional MAC algorithms are initialized. + /// This is true when AEAD keys are initialized in both directions + /// (GCM, ChaCha20-Poly1305), or when traditional MAC algorithms are + /// initialized in both directions. bool get hasIntegrityProtection { - final usingAead = (_localAeadKey != null || - _localChaChaEncKey != null || - _remoteAeadKey != null || - _remoteChaChaEncKey != null); - if (usingAead) return true; + final usingAeadLocal = _localAeadKey != null || _localChaChaEncKey != null; + final usingAeadRemote = _remoteAeadKey != null || _remoteChaChaEncKey != null; + if (usingAeadLocal && usingAeadRemote) return true; return _localMac != null && _remoteMac != null; } - /// Compose 12-byte AEAD nonce from 8-byte fixed IV and 32-bit sequence number. - Uint8List _composeAeadNonce(Uint8List fixed, int seq) { - if (fixed.length < 12) { - throw StateError('AEAD fixed nonce must be at least 12 bytes'); - } - final nonce = Uint8List(12); - nonce[0] = (seq >>> 24) & 0xff; - nonce[1] = (seq >>> 16) & 0xff; - nonce[2] = (seq >>> 8) & 0xff; - nonce[3] = (seq) & 0xff; - nonce.setRange(4, 12, fixed); - return nonce; - } - /// Initiates a client-side re-key operation. This can be called /// by client code to refresh session keys when needed. void rekey() { From 5e3db5494064bd8b2bae68e945bf93a1d6071fca Mon Sep 17 00:00:00 2001 From: GT610 Date: Tue, 31 Mar 2026 12:11:26 +0800 Subject: [PATCH 24/24] fix: Fixed issues with connection limits and concurrent dialing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The condition for checking the number of connections should be “less than or equal to” rather than “less than” Added the _dialing flag to prevent concurrent dialing requests --- lib/src/dynamic_forward_io.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/src/dynamic_forward_io.dart b/lib/src/dynamic_forward_io.dart index d4f2182..b50059a 100644 --- a/lib/src/dynamic_forward_io.dart +++ b/lib/src/dynamic_forward_io.dart @@ -64,7 +64,7 @@ class _SSHDynamicForwardImpl implements SSHDynamicForward { client, options: options, filter: filter, - canOpenTunnel: () => _connections.length < options.maxConnections, + canOpenTunnel: () => _connections.length <= options.maxConnections, dial: dial, onClosed: () => _connections.remove(connection), ); @@ -114,6 +114,7 @@ class _SocksConnection { StreamSubscription? _remoteSub; Timer? _handshakeTimer; bool _closed = false; + bool _dialing = false; _SocksState _state = _SocksState.greeting; void start() { @@ -187,6 +188,9 @@ class _SocksConnection { } if (_state == _SocksState.request) { + if (_dialing) return; + _dialing = true; + final target = _parseConnectRequest(); if (target == null) return; @@ -198,6 +202,7 @@ class _SocksConnection { if (!canOpenTunnel()) { _sendReply(_SocksReply.connectionRefused); + _dialing = false; await close(); return; } @@ -211,10 +216,13 @@ class _SocksConnection { ); } catch (_) { _sendReply(_SocksReply.hostUnreachable); + _dialing = false; await close(); return; } + _dialing = false; + if (_closed) { _remote?.destroy(); _remote = null;