diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d5529..91a1842 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; 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]. @@ -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..ce38b86 100644 --- a/README.md +++ b/README.md @@ -625,9 +625,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/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_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..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'; @@ -40,6 +41,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(); @@ -111,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 new file mode 100644 index 0000000..6718738 --- /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/msg_kex.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, '_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(); + }); + + 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(); + }); + }); +} + +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 {} +}