diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cd9c5b..b9ee4fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## [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 [#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 - **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 +201,15 @@ [#145]: https://github.com/TerminalStudio/dartssh2/pull/145 [#141]: https://github.com/TerminalStudio/dartssh2/pull/141 [#140]: https://github.com/TerminalStudio/dartssh2/pull/140 +[#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 +[#26]: https://github.com/TerminalStudio/dartssh2/issues/26 [#139]: https://github.com/TerminalStudio/dartssh2/pull/139 [#133]: https://github.com/TerminalStudio/dartssh2/pull/133 [#132]: https://github.com/TerminalStudio/dartssh2/pull/132 diff --git a/README.md b/README.md index 6018799..2586545 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 @@ -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 @@ -296,6 +300,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 @@ -591,9 +629,39 @@ void main() async { - `diffie-hellman-group1-sha1 ` **Cipher**: +- `aes[128|256]-gcm@openssh.com` - `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**: - `hmac-md5` - `hmac-sha1` diff --git a/example/forward_dynamic.dart b/example/forward_dynamic.dart new file mode 100644 index 0000000..93115ce --- /dev/null +++ b/example/forward_dynamic.dart @@ -0,0 +1,55 @@ +import 'dart:async'; +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, + onVerifyHostKey: (host, verifier) { + print('WARNING: Host key verification disabled for testing.'); + return true; + }, + ); + + final dynamicForward = await client.forwardDynamic( + bindHost: '127.0.0.1', + bindPort: 1080, + ); + + print( + 'SOCKS5 proxy ready on ${dynamicForward.host}:${dynamicForward.port}.', + ); + print('Press Ctrl+C to stop.'); + + StreamSubscription? sigintSub; + StreamSubscription? sigtermSub; + var isShuttingDown = false; + + Future shutdown() async { + if (isShuttingDown) return; + isShuttingDown = true; + + 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 client.done; + await shutdown(); +} diff --git a/lib/src/algorithm/ssh_cipher_type.dart b/lib/src/algorithm/ssh_cipher_type.dart index ac8d776..5428d6e 100644 --- a/lib/src/algorithm/ssh_cipher_type.dart +++ b/lib/src/algorithm/ssh_cipher_type.dart @@ -5,14 +5,14 @@ import 'package:pointycastle/export.dart'; class SSHCipherType extends SSHAlgorithm { static const values = [ + aes128gcm, + aes256gcm, aes128cbc, aes192cbc, aes256cbc, aes128ctr, aes192ctr, aes256ctr, - aes128gcm, - aes256gcm, chacha20poly1305, ]; @@ -34,6 +34,26 @@ 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, + cipherFactory: _aesGcmFactory, + ); + + static const aes256gcm = SSHCipherType._( + name: 'aes256-gcm@openssh.com', + keySize: 32, + isAead: true, + ivSize: 12, + blockSize: 16, + aeadTagSize: 16, + cipherFactory: _aesGcmFactory, + ); + static const aes128cbc = SSHCipherType._( name: 'aes128-cbc', keySize: 16, @@ -52,28 +72,14 @@ class SSHCipherType extends SSHAlgorithm { cipherFactory: _aesCbcFactory, ); - static const aes128gcm = SSHCipherType._( - name: 'aes128-gcm@openssh.com', - keySize: 16, - cipherFactory: _aesGcmFactory, - isAEAD: true, - tagSize: 16, - ); - - static const aes256gcm = SSHCipherType._( - name: 'aes256-gcm@openssh.com', - keySize: 32, - cipherFactory: _aesGcmFactory, - isAEAD: true, - tagSize: 16, - ); - static const chacha20poly1305 = SSHCipherType._( name: 'chacha20-poly1305@openssh.com', - keySize: 32, // ChaCha20-Poly1305 uses a single 256-bit key + keySize: 32, + isAead: true, + ivSize: 12, + blockSize: 16, + aeadTagSize: 16, cipherFactory: _chacha20Poly1305Factory, - isAEAD: true, - tagSize: 16, ); static SSHCipherType? fromName(String name) { @@ -88,37 +94,42 @@ class SSHCipherType extends SSHAlgorithm { const SSHCipherType._({ required this.name, required this.keySize, - required this.cipherFactory, - this.isAEAD = false, - this.tagSize = 0, + this.cipherFactory, + this.isAead = false, + this.aeadTagSize = 0, + this.ivSize = 16, + this.blockSize = 16, }); - /// The name of the algorithm. For example, `"aes256-ctr`"`. @override final String name; final int keySize; - final int ivSize = 16; + final bool isAead; - final int blockSize = 16; + final int aeadTagSize; - /// Whether this is an AEAD cipher mode - final bool isAEAD; + final int ivSize; - /// Authentication tag size for AEAD modes - final int tagSize; + final int blockSize; - final dynamic Function() cipherFactory; + final dynamic Function()? cipherFactory; - /// Creates cipher for non-AEAD modes BlockCipher createCipher( Uint8List key, Uint8List iv, { required bool forEncryption, }) { - if (isAEAD) { - throw StateError('Use createAEADCipher for AEAD modes'); + if (isAead) { + throw UnsupportedError( + 'AEAD ciphers are packet-level and do not expose BlockCipher', + ); + } + + final factory = cipherFactory; + if (factory == null) { + throw StateError('No block cipher factory configured for $name'); } if (key.length != keySize) { @@ -129,21 +140,18 @@ class SSHCipherType extends SSHAlgorithm { throw ArgumentError.value(iv, 'iv', 'IV must be $ivSize bytes long'); } - final cipher = cipherFactory() as BlockCipher; + final cipher = factory(); cipher.init(forEncryption, ParametersWithIV(KeyParameter(key), iv)); return cipher; } - /// Creates cipher for AEAD modes. Returns a dynamic AEAD cipher instance - /// (either AEADBlockCipher like GCM, or AEADCipher like ChaCha20-Poly1305) - /// supporting `init(...)` and `process(Uint8List)`. dynamic createAEADCipher( Uint8List key, Uint8List nonce, { required bool forEncryption, Uint8List? aad, }) { - if (!isAEAD) { + if (!isAead) { throw StateError('Use createCipher for non-AEAD modes'); } @@ -151,10 +159,23 @@ class SSHCipherType extends SSHAlgorithm { throw ArgumentError.value(key, 'key', 'Key must be $keySize bytes long'); } - final cipher = cipherFactory(); + 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'); + } + + final cipher = factory(); final params = AEADParameters( KeyParameter(key), - tagSize * 8, // tagSize in bits + aeadTagSize * 8, nonce, aad ?? Uint8List(0), ); @@ -172,13 +193,10 @@ BlockCipher _aesCbcFactory() { return CBCBlockCipher(AESEngine()); } -/// Creates AES-GCM cipher factory dynamic _aesGcmFactory() { return GCMBlockCipher(AESEngine()); } -/// Creates ChaCha20-Poly1305 AEAD cipher factory dynamic _chacha20Poly1305Factory() { - // Construct AEAD from underlying ChaCha20 engine and Poly1305 MAC return ChaCha20Poly1305(ChaCha7539Engine(), Poly1305()); } 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..b50059a --- /dev/null +++ b/lib/src/dynamic_forward_io.dart @@ -0,0 +1,416 @@ +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), + ); + + connection.start(); + _connections.add(connection); + } + + @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; + bool _dialing = false; + _SocksState _state = _SocksState.greeting; + + void start() { + _clientSub = _client.listen( + _onClientData, + onDone: _handleClientEOF, + onError: (_, __) => close(), + cancelOnError: true, + ); + + _handshakeTimer = Timer(options.handshakeTimeout, () async { + _sendReply(_SocksReply.ttlExpired); + await close(); + }); + } + + void _handleClientEOF() { + if (_state == _SocksState.streaming) { + _remote?.sink.close(); + _clientSub?.cancel(); + } else { + close(); + } + } + + void _handleRemoteEOF() { + if (_state == _SocksState.streaming) { + _client.destroy(); + _remoteSub?.cancel(); + } else { + close(); + } + } + + Future close() async { + 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) { + if (_dialing) return; + _dialing = true; + + 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); + _dialing = false; + await close(); + return; + } + + _handshakeTimer?.cancel(); + _handshakeTimer = null; + + try { + _remote = await dial(target.host, target.port).timeout( + options.connectTimeout, + ); + } catch (_) { + _sendReply(_SocksReply.hostUnreachable); + _dialing = false; + await close(); + return; + } + + _dialing = false; + + if (_closed) { + _remote?.destroy(); + _remote = null; + return; + } + + _remoteSub = _remote!.stream.listen( + _client.add, + onDone: _handleRemoteEOF, + onError: (_, __) => close(), + cancelOnError: true, + ); + + _sendReply(_SocksReply.succeeded); + _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, allowMalformed: true); + } + + 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 { + 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); + } + + 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/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/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. 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_client.dart b/lib/src/ssh_client.dart index b1d97e4..91360be 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -4,8 +4,9 @@ import 'dart:typed_data'; import 'package:dartssh2/src/http/http_client.dart'; import 'package:dartssh2/src/sftp/sftp_client.dart'; -import 'package:dartssh2/src/ssh_agent.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'; import 'package:dartssh2/src/message/base.dart'; import 'package:dartssh2/src/ssh_channel_id.dart'; @@ -453,6 +454,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. @@ -1707,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_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; 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 1d3bc7a..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 @@ -188,6 +187,14 @@ class SSHTransport { Uint8List? _remoteChaChaEncKey; // payload decryption / poly1305 key generator Uint8List? _remoteChaChaLenKey; // length field decryption key + Uint8List? _localCipherKey; + + Uint8List? _remoteCipherKey; + + Uint8List? _localIV; + + Uint8List? _remoteIV; + /// A [Mac] used to authenticate data sent to the other side. Mac? _localMac; @@ -198,6 +205,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; @@ -227,14 +236,39 @@ class SSHTransport { final clientMacType = _clientMacType; final serverMacType = _serverMacType; final macType = isClient ? clientMacType : serverMacType; - final isEtm = _encryptCipher != null && macType != null && macType.isEtm; + final localCipherType = isClient ? _clientCipherType : _serverCipherType; - final ctLocal = isClient ? _clientCipherType : _serverCipherType; - final usingAead = ctLocal?.isAEAD ?? false; - final isChaCha = ctLocal?.name == 'chacha20-poly1305@openssh.com'; + final usingAead = localCipherType?.isAead ?? false; + final isChaCha = localCipherType?.name == 'chacha20-poly1305@openssh.com'; final aeadReady = isChaCha ? (_localChaChaEncKey != null && _localChaChaLenKey != null) - : (_localAeadKey != null && _localAeadFixedNonce != 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; if (isEtm) { final blockSize = _encryptCipher!.blockSize; @@ -278,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.tagSize; - 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.tagSize; - - 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; @@ -363,6 +349,73 @@ class SSHTransport { } } + 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] = _paddingRandom.nextInt(256); + } + + 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); + + final bytes = buffer.takeBytes(); + _bytesSent += bytes.length; + socket.sink.add(bytes); + } + + 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; @@ -488,17 +541,17 @@ class SSHTransport { /// `null` if there is not enough data in the buffer to read the packet. Uint8List? _consumePacket() { final ct = isClient ? _serverCipherType : _clientCipherType; - final usingAead = ct?.isAEAD ?? false; + final usingAead = ct?.isAead ?? false; if (usingAead) { final isChaCha = ct?.name == 'chacha20-poly1305@openssh.com'; final aeadReady = isChaCha ? (_remoteChaChaEncKey != null && _remoteChaChaLenKey != null) : (_remoteAeadKey != null && _remoteAeadFixedNonce != null); if (aeadReady) { - return _consumeAeadPacket(); + return _consumeAeadPacket(ct!); } } - return _decryptCipher == null + return (_decryptCipher == null && _remoteCipherKey == null) ? _consumeClearTextPacket() : _consumeEncryptedPacket(); } @@ -528,6 +581,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; @@ -639,76 +700,52 @@ class SSHTransport { /// Layout: /// - 4-byte packet length (plaintext, used as AAD) /// - encrypted (padding_length + payload + padding) - /// - authentication tag (cipherType.tagSize) - Uint8List? _consumeAeadPacket() { + /// - authentication tag (cipherType.aeadTagSize) + Uint8List? _consumeAeadPacket(SSHCipherType cipherType) { printDebug?.call('SSHTransport._consumeAeadPacket'); if (_buffer.length < 4) { return null; } - final cipherType = isClient ? _serverCipherType! : _clientCipherType!; - final tagSize = cipherType.tagSize; - if (cipherType.name == 'chacha20-poly1305@openssh.com') { return _consumeChaChaOpenSSHPacket(); } - final len = SSHPacket.readPacketLength(_buffer.data); - _verifyPacketLength(len); + final packetLength = SSHPacket.readPacketLength(_buffer.data); + _verifyPacketLength(packetLength); - final totalNeeded = 4 + len + tagSize; - if (_buffer.length < totalNeeded) { + final tagLength = cipherType.aeadTagSize; + if (_buffer.length < 4 + packetLength + tagLength) { return null; } - final lenBytes = _buffer.consume(4); - final ciphertext = _buffer.consume(len); - final tag = _buffer.consume(tagSize); + final aad = _buffer.consume(4); + final ciphertext = _buffer.consume(packetLength); + final tag = _buffer.consume(tagLength); - final key = _remoteAeadKey!; - final fixedNonce = _remoteAeadFixedNonce!; // 12 bytes - final nonce = _composeAeadNonce(fixedNonce, _remotePacketSN.value); + final encryptedInput = Uint8List(packetLength + tagLength) + ..setRange(0, packetLength, ciphertext) + ..setRange(packetLength, packetLength + tagLength, tag); - final aead = cipherType.createAEADCipher( - key, - nonce, - forEncryption: false, - aad: lenBytes, - ); - - // Concatenate ciphertext + tag for processing - final encWithTagBuilder = BytesBuilder(copy: false) - ..add(ciphertext) - ..add(tag); - final encWithTag = encWithTagBuilder.takeBytes(); - - // Decrypt and authenticate - Uint8List decrypted; + late Uint8List plaintext; try { - final outLen = aead.getOutputSize(encWithTag.length); - decrypted = Uint8List(outLen); - var written = - aead.processBytes(encWithTag, 0, encWithTag.length, decrypted, 0); - written += aead.doFinal(decrypted, written); - if (written != decrypted.length) { - decrypted = Uint8List.sublistView(decrypted, 0, written); - } - } on Exception catch (e) { - // Normalize AEAD auth/tag failures to SSHPacketError - throw SSHPacketError('AEAD decrypt/authentication failed: $e'); - } - - // decrypted = [padding_length | payload | padding] - if (decrypted.isEmpty) { - throw SSHPacketError('AEAD decrypted empty packet body'); + plaintext = _processAead( + key: _remoteAeadKey!, + iv: _remoteAeadFixedNonce!, + sequence: _remotePacketSN.value, + aad: aad, + input: encryptedInput, + forEncryption: false, + ); + } on InvalidCipherTextException { + throw SSHPacketError('AEAD authentication failed'); } - final paddingLength = ByteData.sublistView(decrypted).getUint8(0); - final payloadLength = len - paddingLength - 1; + final paddingLength = plaintext[0]; + final payloadLength = packetLength - paddingLength - 1; _verifyPacketPadding(payloadLength, paddingLength); - - return Uint8List.sublistView(decrypted, 1, 1 + payloadLength); + return Uint8List.sublistView(plaintext, 1, 1 + payloadLength); } void _verifyPacketLength(int packetLength) { @@ -859,7 +896,7 @@ class SSHTransport { _verifyPacketLength(len); final cipherType = isClient ? _serverCipherType! : _clientCipherType!; - final tagSize = cipherType.tagSize; + final tagSize = cipherType.aeadTagSize; final totalNeeded = 4 + len + tagSize; if (_buffer.length < totalNeeded) { return null; @@ -948,7 +985,7 @@ class SSHTransport { final cipherType = isClient ? _clientCipherType : _serverCipherType; if (cipherType == null) throw StateError('No cipher type selected'); - if (cipherType.isAEAD) { + if (cipherType.isAead) { if (cipherType.name == 'chacha20-poly1305@openssh.com') { // OpenSSH Chacha20-Poly1305 derives 64 bytes per direction. final rawKey = _deriveKey( @@ -959,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( @@ -971,7 +1007,8 @@ class SSHTransport { cipherType.ivSize, ); _localAeadKey = key; - _localAeadFixedNonce = Uint8List.sublistView(iv, 0, 12); + _localCipherKey = key; + _localIV = iv; } _encryptCipher = null; _localMac = null; // AEAD provides integrity @@ -999,6 +1036,10 @@ class SSHTransport { ); _localMac = macType.createMac(macKey); + + _localAeadKey = null; + _localCipherKey = null; + _localIV = null; } } @@ -1006,7 +1047,7 @@ class SSHTransport { final cipherType = isClient ? _serverCipherType : _clientCipherType; if (cipherType == null) throw StateError('No cipher type selected'); - if (cipherType.isAEAD) { + if (cipherType.isAead) { if (cipherType.name == 'chacha20-poly1305@openssh.com') { // Derive 64 bytes per direction and split according to OpenSSH spec. final rawKey = _deriveKey( @@ -1029,6 +1070,8 @@ class SSHTransport { ); _remoteAeadKey = key; _remoteAeadFixedNonce = Uint8List.sublistView(iv, 0, 12); + _remoteCipherKey = key; + _remoteIV = iv; } _decryptCipher = null; _remoteMac = null; // AEAD provides integrity @@ -1055,6 +1098,10 @@ class SSHTransport { macType.keySize, ); _remoteMac = macType.createMac(macKey); + + _remoteAeadKey = null; + _remoteCipherKey = null; + _remoteIV = null; } } @@ -1396,10 +1443,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'); } @@ -1637,25 +1684,24 @@ 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 { - final usingAead = (_clientCipherType?.isAEAD == true) || - (_serverCipherType?.isAEAD == true); - if (usingAead) 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; + /// Returns true if integrity protection is provided. + /// + /// 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 usingAeadLocal = _localAeadKey != null || _localChaChaEncKey != null; + final usingAeadRemote = _remoteAeadKey != null || _remoteChaChaEncKey != null; + if (usingAeadLocal && usingAeadRemote) return true; + return _localMac != null && _remoteMac != null; } /// Initiates a client-side re-key operation. This can be called diff --git a/pubspec.yaml b/pubspec.yaml index 7627890..aa93319 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 diff --git a/test/src/algorithm/ssh_cipher_type_test.dart b/test/src/algorithm/ssh_cipher_type_test.dart index 70755d9..900e2f1 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'; @@ -43,6 +44,64 @@ 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('fromName resolves AES-GCM ciphers', () { + expect( + SSHCipherType.fromName('aes128-gcm@openssh.com'), + SSHCipherType.aes128gcm, + ); + expect( + SSHCipherType.fromName('aes256-gcm@openssh.com'), + 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', () { final algorithms = SSHAlgorithms(); @@ -118,6 +177,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); @@ -133,7 +214,7 @@ void testCipher(SSHCipherType type) { void testAEADCipher(SSHCipherType type) { test('$type AEAD encrypt/decrypt', () { - expect(type.isAEAD, isTrue, reason: 'Expected AEAD cipher'); + expect(type.isAead, isTrue, reason: 'Expected AEAD cipher'); final key = Uint8List(type.keySize); final nonce = Uint8List( @@ -149,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.tagSize)); + expect( + encryptedWithTag.length, equals(plainText.length + type.aeadTagSize)); } else { // ChaCha20-Poly1305 requires doFinal to append tag final outLen = encrypter.getOutputSize(plainText.length); @@ -157,7 +239,7 @@ void testAEADCipher(SSHCipherType type) { var written = encrypter.processBytes( plainText, 0, plainText.length, encryptedWithTag, 0); written += encrypter.doFinal(encryptedWithTag, written); - expect(written, equals(plainText.length + type.tagSize)); + expect(written, equals(plainText.length + type.aeadTagSize)); // Trim if underlying allocated larger buffer if (written != encryptedWithTag.length) { encryptedWithTag = Uint8List.sublistView(encryptedWithTag, 0, written); 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..6698a28 --- /dev/null +++ b/test/src/socket/dynamic_forward_io_test.dart @@ -0,0 +1,481 @@ +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/base.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 + }); + + 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(':')); + }); + }); +} + +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_forward_dynamic_test.dart b/test/src/ssh_client_forward_dynamic_test.dart new file mode 100644 index 0000000..1df5076 --- /dev/null +++ b/test/src/ssh_client_forward_dynamic_test.dart @@ -0,0 +1,90 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/src/message/base.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, + ); + + // 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()); + }); + + 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 {} +} diff --git a/test/src/ssh_client_test.dart b/test/src/ssh_client_test.dart index 2c7ec21..a13ccb1 100644 --- a/test/src/ssh_client_test.dart +++ b/test/src/ssh_client_test.dart @@ -181,6 +181,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(); diff --git a/test/src/ssh_transport_aead_test.dart b/test/src/ssh_transport_aead_test.dart new file mode 100644 index 0000000..7246460 --- /dev/null +++ b/test/src/ssh_transport_aead_test.dart @@ -0,0 +1,535 @@ +import 'dart:async'; +import 'dart:mirrors'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/src/message/base.dart'; +import 'package:dartssh2/src/ssh_packet.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); + } + + 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; + 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, '_remoteAeadKey', key); + setPrivate(receiver, '_remoteAeadFixedNonce', 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, '_remoteAeadKey', key); + setPrivate(receiver, '_remoteAeadFixedNonce', 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(); + }); + + 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, '_remoteAeadKey', Uint8List(16)); + setPrivate(transport, '_remoteAeadFixedNonce', 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(); + }); + }); +} + +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 {} +}