From 3c074674065e542f6ad233038784bb400e683554 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:34:47 +0200 Subject: [PATCH 1/8] Refactor KEX handling to async and offload X25519 crypto --- lib/src/kex/kex_x25519.dart | 43 +++++++++++-- lib/src/ssh_transport.dart | 91 ++++++++++++++++----------- lib/src/utils/compute.dart | 7 +++ lib/src/utils/compute_io.dart | 63 +++++++++++++++++++ lib/src/utils/compute_stub.dart | 6 ++ test/src/ssh_transport_aead_test.dart | 38 ++++++----- 6 files changed, 192 insertions(+), 56 deletions(-) create mode 100644 lib/src/utils/compute.dart create mode 100644 lib/src/utils/compute_io.dart create mode 100644 lib/src/utils/compute_stub.dart diff --git a/lib/src/kex/kex_x25519.dart b/lib/src/kex/kex_x25519.dart index 77a8ff6..18e32f4 100644 --- a/lib/src/kex/kex_x25519.dart +++ b/lib/src/kex/kex_x25519.dart @@ -1,21 +1,36 @@ import 'dart:typed_data'; import 'package:dartssh2/src/ssh_kex.dart'; +import 'package:dartssh2/src/utils/compute.dart'; import 'package:dartssh2/src/utils/bigint.dart'; import 'package:dartssh2/src/utils/list.dart'; import 'package:pinenacl/tweetnacl.dart'; class SSHKexX25519 implements SSHKexECDH { /// Randomly generated private key. - late final Uint8List privateKey; + final Uint8List privateKey; /// Public key computed from the private key. @override - late final Uint8List publicKey; + final Uint8List publicKey; + + factory SSHKexX25519() { + final privateKey = randomBytes(32); + final publicKey = _ScalarMult.scalseMultBase(privateKey); + return SSHKexX25519._( + privateKey: privateKey, + publicKey: publicKey, + ); + } + + SSHKexX25519._({required this.privateKey, required this.publicKey}); - SSHKexX25519() { - privateKey = randomBytes(32); - publicKey = _ScalarMult.scalseMultBase(privateKey); + static Future createAsync() async { + final keyPair = await sshCompute(_computeX25519KeyPair, null); + return SSHKexX25519._( + privateKey: keyPair[0], + publicKey: keyPair[1], + ); } @override @@ -23,6 +38,24 @@ class SSHKexX25519 implements SSHKexECDH { final secret = _ScalarMult.scalseMult(privateKey, remotePublicKey); return decodeBigIntWithSign(1, secret); } + + Future computeSecretAsync(Uint8List remotePublicKey) async { + final secret = await sshCompute( + _computeX25519Secret, + [privateKey, remotePublicKey], + ); + return decodeBigIntWithSign(1, secret); + } +} + +List _computeX25519KeyPair(void _) { + final privateKey = randomBytes(32); + final publicKey = _ScalarMult.scalseMultBase(privateKey); + return [privateKey, publicKey]; +} + +Uint8List _computeX25519Secret(List data) { + return _ScalarMult.scalseMult(data[0], data[1]); } /// Scalar multiplication, Implements curve25519. diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index d66a245..38e28cf 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -114,6 +114,9 @@ class SSHTransport { /// transport is closed. StreamSubscription? _socketSubscription; + /// Guards asynchronous packet processing to preserve message order. + var _isProcessingData = false; + /// Identification string sent by us without trailing \r\n. For example, /// "SSH-2.0-DartSSH_2.0". String get _localVersion => 'SSH-2.0-$version'; @@ -408,13 +411,7 @@ class SSHTransport { void _onSocketData(Uint8List data) { _buffer.add(data); - try { - _processData(); - } on SSHError catch (e, stackTrace) { - closeWithError(e, stackTrace); - } catch (e) { - rethrow; - } + _scheduleProcessData(); } void _onSocketError(Object error, StackTrace stackTrace) { @@ -427,11 +424,33 @@ class SSHTransport { close(); } - void _processData() { + void _scheduleProcessData() { + if (_isProcessingData || isClosed) { + return; + } + + _isProcessingData = true; + + _processDataAsync().catchError((error, stackTrace) { + if (error is SSHError) { + closeWithError(error, stackTrace); + } else { + closeWithError(SSHInternalError(error), stackTrace); + } + }).whenComplete(() { + _isProcessingData = false; + if (_buffer.isNotEmpty && !isClosed) { + _scheduleProcessData(); + } + }); + } + + Future _processDataAsync() async { if (_remoteVersion == null) { _processVersionExchange(); - } else { - _processPackets(); + } + if (_remoteVersion != null) { + await _processPackets(); } } @@ -473,12 +492,12 @@ class SSHTransport { _sendKexInit(); } - // There maybe more data in the buffer, so process it. - _processPackets(); + // There maybe more data in the buffer, so it will be consumed by the + // asynchronous packet processing queue. } /// Process one or more SSH packets queued in [_buffer]. - void _processPackets() { + Future _processPackets() async { printDebug?.call('SSHTransport._processPackets'); while (_buffer.isNotEmpty && !isClosed) { @@ -491,7 +510,7 @@ class SSHTransport { // throw SSHPacketError('Packet too long: ${payload.length}'); // } - _handleMessage(payload); + await _handleMessage(payload); _remotePacketSN.increase(); } @@ -980,7 +999,7 @@ class SSHTransport { sendPacket(message.encode()); } - void _handleMessage(Uint8List message) { + Future _handleMessage(Uint8List message) async { final messageId = SSHMessage.readMessageId(message); switch (messageId) { case SSH_Message_KexInit.messageId: @@ -995,7 +1014,7 @@ class SSHTransport { } } - void _handleMessageKexInit(Uint8List payload) { + Future _handleMessageKexInit(Uint8List payload) async { printDebug?.call('SSHTransport._handleMessageKexInit'); // If this message initiates a new key-exchange round from the remote @@ -1073,7 +1092,7 @@ class SSHTransport { switch (_kexType) { case SSHKexType.x25519: - _kex = SSHKexX25519(); + _kex = await SSHKexX25519.createAsync(); break; case SSHKexType.nistp256: _kex = SSHKexNist.p256(); @@ -1107,7 +1126,7 @@ class SSHTransport { /// When client receives [SSH_Message_KexECDH_Reply], it should verify the /// server's signature with the server's public key. Then send NEW_KEYS /// message back to the server. - void _handleMessageKexReply(Uint8List payload) { + Future _handleMessageKexReply(Uint8List payload) async { printDebug?.call('SSHTransport._handleMessageKexReply'); if (isServer) throw SSHStateError('Unexpected KEX_REPLY'); @@ -1149,7 +1168,11 @@ class SSHTransport { hostSignature = message.signature; serverKexKey = message.ecdhPublicKey; clientKexKey = kex.publicKey; - sharedSecret = kex.computeSecret(message.ecdhPublicKey); + if (kex is SSHKexX25519) { + sharedSecret = await kex.computeSecretAsync(message.ecdhPublicKey); + } else { + sharedSecret = kex.computeSecret(message.ecdhPublicKey); + } } else { throw UnimplementedError('$kex'); } @@ -1189,27 +1212,21 @@ class SSHTransport { } final userVerified = onVerifyHostKey != null - ? onVerifyHostKey!(_hostkeyType!.name, fingerprint) + ? await Future.value(onVerifyHostKey!(_hostkeyType!.name, fingerprint)) : true; - Future.value(userVerified).then( - (verified) { - if (!verified) { - closeWithError(SSHHostkeyError('Hostkey verification failed')); - } else { - _hostkeyVerified = true; - _sendNewKeys(); - _applyLocalKeys(); - onReady?.call(); - } - }, - onError: (error) { - closeWithError(error); - }, - ); + if (!userVerified) { + closeWithError(SSHHostkeyError('Hostkey verification failed')); + return; + } + + _hostkeyVerified = true; + _sendNewKeys(); + _applyLocalKeys(); + onReady?.call(); } - void _handleMessageKexGexReply(Uint8List payload) { + Future _handleMessageKexGexReply(Uint8List payload) async { printDebug?.call('SSHTransport._handleMessageKexGexReply'); if (isServer) throw SSHStateError('Unexpected KEX_GEX_REPLY'); @@ -1220,7 +1237,7 @@ class SSHTransport { _sendKexDHGexInit(); } - void _handleMessageNewKeys(Uint8List message) { + Future _handleMessageNewKeys(Uint8List message) async { printDebug?.call('SSHTransport._handleMessageNewKeys'); printTrace?.call('<- $socket: SSH_Message_NewKeys'); diff --git a/lib/src/utils/compute.dart b/lib/src/utils/compute.dart new file mode 100644 index 0000000..9bc081c --- /dev/null +++ b/lib/src/utils/compute.dart @@ -0,0 +1,7 @@ +import 'compute_stub.dart' if (dart.library.isolate) 'compute_io.dart'; + +typedef SSHComputeCallback = R Function(M message); + +Future sshCompute(SSHComputeCallback callback, M message) { + return sshComputeImpl(callback, message); +} diff --git a/lib/src/utils/compute_io.dart b/lib/src/utils/compute_io.dart new file mode 100644 index 0000000..f0c4154 --- /dev/null +++ b/lib/src/utils/compute_io.dart @@ -0,0 +1,63 @@ +import 'dart:async'; +import 'dart:isolate'; + +class _ComputeConfiguration { + final R Function(M message) callback; + final M message; + final SendPort resultPort; + + const _ComputeConfiguration({ + required this.callback, + required this.message, + required this.resultPort, + }); +} + +class _ComputeError { + final String error; + final String stackTrace; + + const _ComputeError(this.error, this.stackTrace); +} + +void _spawn(_ComputeConfiguration configuration) { + try { + final result = configuration.callback(configuration.message); + Isolate.exit(configuration.resultPort, result); + } catch (error, stackTrace) { + Isolate.exit( + configuration.resultPort, + _ComputeError(error.toString(), stackTrace.toString()), + ); + } +} + +Future sshComputeImpl( + R Function(M message) callback, + M message, +) async { + final resultPort = RawReceivePort(); + final completer = Completer(); + + resultPort.handler = (response) { + resultPort.close(); + if (response is _ComputeError) { + completer.completeError( + RemoteError(response.error, response.stackTrace), + ); + return; + } + completer.complete(response as R); + }; + + await Isolate.spawn<_ComputeConfiguration>( + _spawn, + _ComputeConfiguration( + callback: callback, + message: message, + resultPort: resultPort.sendPort, + ), + ); + + return completer.future; +} diff --git a/lib/src/utils/compute_stub.dart b/lib/src/utils/compute_stub.dart new file mode 100644 index 0000000..5fdd1a2 --- /dev/null +++ b/lib/src/utils/compute_stub.dart @@ -0,0 +1,6 @@ +Future sshComputeImpl( + R Function(M message) callback, + M message, +) { + return Future.sync(() => callback(message)); +} diff --git a/test/src/ssh_transport_aead_test.dart b/test/src/ssh_transport_aead_test.dart index 6718738..84c39e4 100644 --- a/test/src/ssh_transport_aead_test.dart +++ b/test/src/ssh_transport_aead_test.dart @@ -283,7 +283,7 @@ void main() { transport.close(); }); - test('kexinit allows missing MAC when AEAD cipher is selected', () { + test('kexinit allows missing MAC when AEAD cipher is selected', () async { final socket = _CaptureSSHSocket(); final transport = SSHTransport( socket, @@ -308,11 +308,9 @@ void main() { firstKexPacketFollows: false, ).encode(); - expect( - () => reflect(transport) - .invoke(privateSymbol('_handleMessageKexInit'), [payload]), - returnsNormally, - ); + final result = reflect(transport) + .invoke(privateSymbol('_handleMessageKexInit'), [payload]).reflectee; + await expectLater(result, completes); transport.close(); }); @@ -402,7 +400,8 @@ void main() { transport.close(); }); - test('kexinit requires client MAC when non-AEAD cipher is selected', () { + test('kexinit requires client MAC when non-AEAD cipher is selected', + () async { final socket = _CaptureSSHSocket(); final transport = SSHTransport( socket, @@ -427,16 +426,22 @@ void main() { firstKexPacketFollows: false, ).encode(); - expect( - () => reflect(transport) - .invoke(privateSymbol('_handleMessageKexInit'), [payload]), + await expectLater( + () async { + final result = reflect(transport).invoke( + privateSymbol('_handleMessageKexInit'), [payload]).reflectee; + if (result is Future) { + await result; + } + }, throwsA(isA()), ); transport.close(); }); - test('kexinit requires server MAC when non-AEAD cipher is selected', () { + test('kexinit requires server MAC when non-AEAD cipher is selected', + () async { final socket = _CaptureSSHSocket(); final transport = SSHTransport( socket, @@ -461,9 +466,14 @@ void main() { firstKexPacketFollows: false, ).encode(); - expect( - () => reflect(transport) - .invoke(privateSymbol('_handleMessageKexInit'), [payload]), + await expectLater( + () async { + final result = reflect(transport).invoke( + privateSymbol('_handleMessageKexInit'), [payload]).reflectee; + if (result is Future) { + await result; + } + }, throwsA(isA()), ); From 726530b6c2310e5bbf50b3e7dcb13c7905956a18 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:30:43 +0200 Subject: [PATCH 2/8] style: reformat invocation of _handleMessageKexInit for better readability --- test/src/ssh_transport_aead_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/src/ssh_transport_aead_test.dart b/test/src/ssh_transport_aead_test.dart index 92b396f..36b4f7f 100644 --- a/test/src/ssh_transport_aead_test.dart +++ b/test/src/ssh_transport_aead_test.dart @@ -340,8 +340,8 @@ void main() { firstKexPacketFollows: false, ).encode(); - final result = reflect(transport) - .invoke(privateSymbol('_handleMessageKexInit'), [payload]).reflectee; + final result = reflect(transport).invoke( + privateSymbol('_handleMessageKexInit'), [payload]).reflectee; await expectLater(result, completes); transport.close(); From 741b466e8dc47c2b4e53c7fb60b165d44e42a2cc Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:42:28 +0200 Subject: [PATCH 3/8] feat: offload all key exchange operations to isolates for improved performance and responsiveness --- CHANGELOG.md | 7 +++ lib/src/kex/kex_dh.dart | 53 ++++++++++++++++++++ lib/src/kex/kex_nist.dart | 93 +++++++++++++++++++++++++++++++++++ lib/src/kex/kex_x25519.dart | 17 ++++--- lib/src/ssh_key_pair.dart | 2 +- lib/src/ssh_transport.dart | 17 ++++--- lib/src/utils/compute_io.dart | 59 +--------------------- pubspec.yaml | 4 +- 8 files changed, 177 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8159d65..fb2e599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [2.20.0] - 2026-06-30 +- **BREAKING**: Bumped the minimum Dart SDK constraint to `3.0.0` [#23]. Thanks [@vicajilau]. +- **BREAKING**: Declared `OpenSSHKeyPair` as a `mixin class` to comply with Dart 3.0 class modifier rules [#23]. Thanks [@vicajilau]. +- Offloaded all cryptographic key exchange (KEX) calculations to background isolates using `Isolate.run` on platforms that support it, preventing the Flutter main thread from blocking/freezing during connection [#23]. Thanks [@vicajilau]. +- Refactored internal key exchange isolate communication payloads (X25519, NIST Curves, DH) to use Dart 3.0 type-safe Records [#23]. Thanks [@vicajilau]. + ## [2.19.0] - 2026-06-30 - Added tolerant HTTP-date parsing to accept all RFC 7231 §7.1.1.1 HTTP-date formats (`IMF-fixdate`, `RFC 850`, `asctime`) for HTTP response headers [#170]. Thanks [@GT-610]. - Added chunked transfer-encoding decoding for HTTP response bodies according to RFC 7230 §4.1, improving interoperability with HTTP/1.1 servers [#171]. Thanks [@GT-610]. @@ -241,6 +247,7 @@ [#71]: https://github.com/TerminalStudio/dartssh2/issues/71 [#50]: https://github.com/TerminalStudio/dartssh2/issues/50 [#24]: https://github.com/TerminalStudio/dartssh2/issues/24 +[#23]: https://github.com/TerminalStudio/dartssh2/issues/23 [#21]: https://github.com/TerminalStudio/dartssh2/issues/21 [#18]: https://github.com/TerminalStudio/dartssh2/issues/18 [#17]: https://github.com/TerminalStudio/dartssh2/issues/17 diff --git a/lib/src/kex/kex_dh.dart b/lib/src/kex/kex_dh.dart index bf83cd4..c493e44 100644 --- a/lib/src/kex/kex_dh.dart +++ b/lib/src/kex/kex_dh.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:dartssh2/src/ssh_kex.dart'; import 'package:dartssh2/src/utils/bigint.dart'; import 'package:dartssh2/src/utils/list.dart'; +import 'package:dartssh2/src/utils/compute.dart'; /// The Diffie-Hellman (DH) key exchange provides a shared secret that /// cannot be determined by either party alone. @@ -35,6 +36,14 @@ class SSHKexDH implements SSHKex { e = g.modPow(x, p); } + SSHKexDH._({ + required this.p, + required this.g, + required this.secretBits, + required this.x, + required this.e, + }); + /// https://tools.ietf.org/html/rfc2409 Second Oakley Group factory SSHKexDH.group1() { return SSHKexDH(p: _group1Prime, g: BigInt.from(2), secretBits: 160); @@ -45,10 +54,54 @@ class SSHKexDH implements SSHKex { return SSHKexDH(p: _group14Prime, g: BigInt.from(2), secretBits: 224); } + static Future createAsync({ + required BigInt p, + required BigInt g, + required int secretBits, + }) async { + final (x, e) = await sshCompute(_computeDHKeyPair, (p, g, secretBits)); + return SSHKexDH._( + p: p, + g: g, + secretBits: secretBits, + x: x, + e: e, + ); + } + + static Future group1Async() { + return createAsync(p: _group1Prime, g: BigInt.from(2), secretBits: 160); + } + + static Future group14Async() { + return createAsync(p: _group14Prime, g: BigInt.from(2), secretBits: 224); + } + /// Compute the shared secret K BigInt computeSecret(BigInt f) { return f.modPow(x, p); } + + Future computeSecretAsync(BigInt f) async { + return sshCompute(_computeDHSecret, (f, x, p)); + } +} + +(BigInt, BigInt) _computeDHKeyPair((BigInt, BigInt, int) args) { + final p = args.$1; + final g = args.$2; + final secretBits = args.$3; + + final x = decodeBigIntWithSign(1, randomBytes(secretBits ~/ 8)); + final e = g.modPow(x, p); + return (x, e); +} + +BigInt _computeDHSecret((BigInt, BigInt, BigInt) args) { + final f = args.$1; + final x = args.$2; + final p = args.$3; + return f.modPow(x, p); } final _group1Prime = decodeBigIntWithSign( diff --git a/lib/src/kex/kex_nist.dart b/lib/src/kex/kex_nist.dart index 23df1a8..b53e70b 100644 --- a/lib/src/kex/kex_nist.dart +++ b/lib/src/kex/kex_nist.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:dartssh2/src/ssh_kex.dart'; import 'package:dartssh2/src/utils/bigint.dart'; import 'package:dartssh2/src/utils/list.dart'; +import 'package:dartssh2/src/utils/compute.dart'; import 'package:pointycastle/ecc/curves/secp256r1.dart'; import 'package:pointycastle/ecc/curves/secp384r1.dart'; import 'package:pointycastle/ecc/curves/secp521r1.dart'; @@ -31,12 +32,38 @@ class SSHKexNist implements SSHKexECDH { publicKey = c!.getEncoded(false); } + SSHKexNist._({ + required this.curve, + required this.secretBits, + required this.privateKey, + required this.publicKey, + }); + SSHKexNist.p256() : this(curve: ECCurve_secp256r1(), secretBits: 256); SSHKexNist.p384() : this(curve: ECCurve_secp384r1(), secretBits: 384); SSHKexNist.p521() : this(curve: ECCurve_secp521r1(), secretBits: 521); + static Future p256Async() => createAsync('p256'); + + static Future p384Async() => createAsync('p384'); + + static Future p521Async() => createAsync('p521'); + + static Future createAsync(String curveName) async { + final (privateKey, publicKey) = + await sshCompute(_computeNistKeyPair, curveName); + final curve = _getCurveByName(curveName); + final secretBits = _getSecretBitsByName(curveName); + return SSHKexNist._( + curve: curve, + secretBits: secretBits, + privateKey: privateKey, + publicKey: publicKey, + ); + } + /// Compute shared secret. @override BigInt computeSecret(Uint8List remotePubilcKey) { @@ -44,6 +71,14 @@ class SSHKexNist implements SSHKexECDH { return (s * privateKey)!.x!.toBigInteger()!; } + Future computeSecretAsync(Uint8List remotePublicKey) async { + final curveName = _getNameByCurve(curve); + return sshCompute( + _computeNistSecret, + (curveName, privateKey, remotePublicKey), + ); + } + BigInt _generatePrivateKey() { late BigInt x; do { @@ -52,3 +87,61 @@ class SSHKexNist implements SSHKexECDH { return x; } } + +ECDomainParameters _getCurveByName(String name) { + switch (name) { + case 'p256': + return ECCurve_secp256r1(); + case 'p384': + return ECCurve_secp384r1(); + case 'p521': + return ECCurve_secp521r1(); + default: + throw ArgumentError('Unknown curve name: $name'); + } +} + +int _getSecretBitsByName(String name) { + switch (name) { + case 'p256': + return 256; + case 'p384': + return 384; + case 'p521': + return 521; + default: + throw ArgumentError('Unknown curve name: $name'); + } +} + +String _getNameByCurve(ECDomainParameters curve) { + if (curve is ECCurve_secp256r1) return 'p256'; + if (curve is ECCurve_secp384r1) return 'p384'; + if (curve is ECCurve_secp521r1) return 'p521'; + throw ArgumentError('Unknown curve type: $curve'); +} + +(BigInt, Uint8List) _computeNistKeyPair(String curveName) { + final curve = _getCurveByName(curveName); + final secretBits = _getSecretBitsByName(curveName); + + late BigInt x; + do { + x = decodeBigIntWithSign(1, randomBytes(secretBits ~/ 8)) % curve.n; + } while (x == BigInt.zero); + + final c = curve.G * x; + final publicKey = c!.getEncoded(false); + + return (x, publicKey); +} + +BigInt _computeNistSecret((String, BigInt, Uint8List) args) { + final curveName = args.$1; + final privateKey = args.$2; + final remotePublicKey = args.$3; + + final curve = _getCurveByName(curveName); + final s = curve.curve.decodePoint(remotePublicKey)!; + return (s * privateKey)!.x!.toBigInteger()!; +} diff --git a/lib/src/kex/kex_x25519.dart b/lib/src/kex/kex_x25519.dart index 18e32f4..5803cc2 100644 --- a/lib/src/kex/kex_x25519.dart +++ b/lib/src/kex/kex_x25519.dart @@ -26,10 +26,11 @@ class SSHKexX25519 implements SSHKexECDH { SSHKexX25519._({required this.privateKey, required this.publicKey}); static Future createAsync() async { - final keyPair = await sshCompute(_computeX25519KeyPair, null); + final (privateKey, publicKey) = + await sshCompute(_computeX25519KeyPair, null); return SSHKexX25519._( - privateKey: keyPair[0], - publicKey: keyPair[1], + privateKey: privateKey, + publicKey: publicKey, ); } @@ -42,20 +43,20 @@ class SSHKexX25519 implements SSHKexECDH { Future computeSecretAsync(Uint8List remotePublicKey) async { final secret = await sshCompute( _computeX25519Secret, - [privateKey, remotePublicKey], + (privateKey, remotePublicKey), ); return decodeBigIntWithSign(1, secret); } } -List _computeX25519KeyPair(void _) { +(Uint8List, Uint8List) _computeX25519KeyPair(void _) { final privateKey = randomBytes(32); final publicKey = _ScalarMult.scalseMultBase(privateKey); - return [privateKey, publicKey]; + return (privateKey, publicKey); } -Uint8List _computeX25519Secret(List data) { - return _ScalarMult.scalseMult(data[0], data[1]); +Uint8List _computeX25519Secret((Uint8List, Uint8List) data) { + return _ScalarMult.scalseMult(data.$1, data.$2); } /// Scalar multiplication, Implements curve25519. diff --git a/lib/src/ssh_key_pair.dart b/lib/src/ssh_key_pair.dart index 52c2a17..fef8782 100644 --- a/lib/src/ssh_key_pair.dart +++ b/lib/src/ssh_key_pair.dart @@ -280,7 +280,7 @@ class OpenSSHBcryptKdfOptions implements OpenSSHKdfOptions { } } -abstract class OpenSSHKeyPair implements SSHKeyPair { +abstract mixin class OpenSSHKeyPair implements SSHKeyPair { void writeTo(SSHMessageWriter writer); @override diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index f2e4fea..410e0c3 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -1186,20 +1186,20 @@ class SSHTransport { _kex = await SSHKexX25519.createAsync(); break; case SSHKexType.nistp256: - _kex = SSHKexNist.p256(); + _kex = await SSHKexNist.p256Async(); break; case SSHKexType.nistp384: - _kex = SSHKexNist.p384(); + _kex = await SSHKexNist.p384Async(); break; case SSHKexType.nistp521: - _kex = SSHKexNist.p521(); + _kex = await SSHKexNist.p521Async(); break; case SSHKexType.dh14Sha1: case SSHKexType.dh14Sha256: - _kex = SSHKexDH.group14(); + _kex = await SSHKexDH.group14Async(); break; case SSHKexType.dh1Sha1: - _kex = SSHKexDH.group1(); + _kex = await SSHKexDH.group1Async(); break; case SSHKexType.dhGexSha1: case SSHKexType.dhGexSha256: @@ -1251,7 +1251,7 @@ class SSHTransport { hostSignature = message.signature; serverKexKey = encodeBigInt(message.f); clientKexKey = encodeBigInt(kex.e); - sharedSecret = kex.computeSecret(message.f); + sharedSecret = await kex.computeSecretAsync(message.f); } else if (kex is SSHKexECDH) { final message = SSH_Message_KexECDH_Reply.decode(payload); printTrace?.call('<- $socket: $message'); @@ -1261,6 +1261,8 @@ class SSHTransport { clientKexKey = kex.publicKey; if (kex is SSHKexX25519) { sharedSecret = await kex.computeSecretAsync(message.ecdhPublicKey); + } else if (kex is SSHKexNist) { + sharedSecret = await kex.computeSecretAsync(message.ecdhPublicKey); } else { sharedSecret = kex.computeSecret(message.ecdhPublicKey); } @@ -1325,7 +1327,8 @@ class SSHTransport { final message = SSH_Message_KexDH_GexGroup.decode(payload); printTrace?.call('<- $socket: $message'); - _kex = SSHKexDH(p: message.p, g: message.g, secretBits: 256); + _kex = + await SSHKexDH.createAsync(p: message.p, g: message.g, secretBits: 256); _sendKexDHGexInit(); } diff --git a/lib/src/utils/compute_io.dart b/lib/src/utils/compute_io.dart index f0c4154..47622af 100644 --- a/lib/src/utils/compute_io.dart +++ b/lib/src/utils/compute_io.dart @@ -1,63 +1,8 @@ -import 'dart:async'; import 'dart:isolate'; -class _ComputeConfiguration { - final R Function(M message) callback; - final M message; - final SendPort resultPort; - - const _ComputeConfiguration({ - required this.callback, - required this.message, - required this.resultPort, - }); -} - -class _ComputeError { - final String error; - final String stackTrace; - - const _ComputeError(this.error, this.stackTrace); -} - -void _spawn(_ComputeConfiguration configuration) { - try { - final result = configuration.callback(configuration.message); - Isolate.exit(configuration.resultPort, result); - } catch (error, stackTrace) { - Isolate.exit( - configuration.resultPort, - _ComputeError(error.toString(), stackTrace.toString()), - ); - } -} - Future sshComputeImpl( R Function(M message) callback, M message, -) async { - final resultPort = RawReceivePort(); - final completer = Completer(); - - resultPort.handler = (response) { - resultPort.close(); - if (response is _ComputeError) { - completer.completeError( - RemoteError(response.error, response.stackTrace), - ); - return; - } - completer.complete(response as R); - }; - - await Isolate.spawn<_ComputeConfiguration>( - _spawn, - _ComputeConfiguration( - callback: callback, - message: message, - resultPort: resultPort.sendPort, - ), - ); - - return completer.future; +) { + return Isolate.run(() => callback(message)); } diff --git a/pubspec.yaml b/pubspec.yaml index 0af7c6a..27c6933 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,10 +1,10 @@ name: dartssh2 -version: 2.19.0 +version: 2.20.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 environment: - sdk: ">=2.17.0 <4.0.0" + sdk: ">=3.0.0 <4.0.0" dependencies: asn1lib: ^1.5.8 From 738b488e0f496ec051e4e6808a4f99e98c67302b Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:50:39 +0200 Subject: [PATCH 4/8] refactor: remove integration tags and library declarations from test files --- test/src/channel/ssh_channel_test.dart | 3 --- test/src/sftp/sftp_client_test.dart | 3 --- test/src/sftp/sftp_stream_io_test.dart | 3 --- test/src/socket/ssh_socket_io_test.dart | 3 --- test/src/ssh_client_test.dart | 3 --- 5 files changed, 15 deletions(-) diff --git a/test/src/channel/ssh_channel_test.dart b/test/src/channel/ssh_channel_test.dart index 17183e1..ac3a09f 100644 --- a/test/src/channel/ssh_channel_test.dart +++ b/test/src/channel/ssh_channel_test.dart @@ -1,6 +1,3 @@ -@Tags(['integration']) -library ssh_channel_test; - import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/sftp/sftp_client_test.dart b/test/src/sftp/sftp_client_test.dart index 8cd8bc7..0cacc85 100644 --- a/test/src/sftp/sftp_client_test.dart +++ b/test/src/sftp/sftp_client_test.dart @@ -1,6 +1,3 @@ -@Tags(['integration']) -library sftp_client_test; - import 'dart:io'; import 'package:dartssh2/dartssh2.dart'; diff --git a/test/src/sftp/sftp_stream_io_test.dart b/test/src/sftp/sftp_stream_io_test.dart index 84b149f..5693fea 100644 --- a/test/src/sftp/sftp_stream_io_test.dart +++ b/test/src/sftp/sftp_stream_io_test.dart @@ -1,6 +1,3 @@ -@Tags(['integration']) -library sftp_stream_io_test; - import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/socket/ssh_socket_io_test.dart b/test/src/socket/ssh_socket_io_test.dart index 9ae9fe5..626f678 100644 --- a/test/src/socket/ssh_socket_io_test.dart +++ b/test/src/socket/ssh_socket_io_test.dart @@ -1,6 +1,3 @@ -@Tags(['integration']) -library ssh_socket_io_test; - import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/ssh_client_test.dart b/test/src/ssh_client_test.dart index 555ce4e..af35f66 100644 --- a/test/src/ssh_client_test.dart +++ b/test/src/ssh_client_test.dart @@ -1,6 +1,3 @@ -@Tags(['integration']) -library ssh_client_test; - import 'dart:convert'; import 'package:dartssh2/dartssh2.dart'; From ca58e4fca1e992ebd15fb32ef1b53104d65f492d Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:05:31 +0200 Subject: [PATCH 5/8] docs: document non-blocking key exchange and update PEM decryption example with record types --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6c6dae3..24f79da 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as e - **Authentication**: Supports password, private key and interactive authentication method. - **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. +- **Non-blocking Key Exchange**: Automatically offloads heavy key exchange calculations (X25519, NIST Curves, DH) to background isolates on supported VM platforms, preventing the main UI thread from freezing during connection. ## 🧬 Built with dartssh2 @@ -370,15 +371,15 @@ void main() async { } ``` -Decrypt PEM file with [`compute`](https://api.flutter.dev/flutter/foundation/compute-constant.html) in Flutter +Decrypt PEM file with [`compute`](https://api.flutter.dev/flutter/foundation/compute-constant.html) in Flutter: ```dart void main() async { - List decryptKeyPairs(List args) { - return SSHKeyPair.fromPem(args[0], args[1]); + List decryptKeyPairs((String pem, String passphrase) args) { + return SSHKeyPair.fromPem(args.$1, args.$2); } - final keypairs = await compute(decryptKeyPairs, ['', '']); + final keypairs = await compute(decryptKeyPairs, ('', '')); } ``` From 2dcf705403469847ee849f427d79b10f8547b88e Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:08:47 +0200 Subject: [PATCH 6/8] test: mark integration test suites with integration tag --- test/src/channel/ssh_channel_test.dart | 3 +++ test/src/sftp/sftp_client_test.dart | 3 +++ test/src/sftp/sftp_stream_io_test.dart | 3 +++ test/src/socket/ssh_socket_io_test.dart | 3 +++ test/src/ssh_client_test.dart | 3 +++ 5 files changed, 15 insertions(+) diff --git a/test/src/channel/ssh_channel_test.dart b/test/src/channel/ssh_channel_test.dart index ac3a09f..9f6420a 100644 --- a/test/src/channel/ssh_channel_test.dart +++ b/test/src/channel/ssh_channel_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/sftp/sftp_client_test.dart b/test/src/sftp/sftp_client_test.dart index 0cacc85..486d475 100644 --- a/test/src/sftp/sftp_client_test.dart +++ b/test/src/sftp/sftp_client_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'dart:io'; import 'package:dartssh2/dartssh2.dart'; diff --git a/test/src/sftp/sftp_stream_io_test.dart b/test/src/sftp/sftp_stream_io_test.dart index 5693fea..4e6f77c 100644 --- a/test/src/sftp/sftp_stream_io_test.dart +++ b/test/src/sftp/sftp_stream_io_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/socket/ssh_socket_io_test.dart b/test/src/socket/ssh_socket_io_test.dart index 626f678..f08b6ae 100644 --- a/test/src/socket/ssh_socket_io_test.dart +++ b/test/src/socket/ssh_socket_io_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/ssh_client_test.dart b/test/src/ssh_client_test.dart index af35f66..12a6f2e 100644 --- a/test/src/ssh_client_test.dart +++ b/test/src/ssh_client_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'dart:convert'; import 'package:dartssh2/dartssh2.dart'; From bb96de640fa6d311471484ebd081059e4a70f85f Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:12:15 +0200 Subject: [PATCH 7/8] test: add asynchronous key exchange and shared secret computation tests for X25519, DH, and NIST curves --- test/src/kex/kex_dh_test.dart | 26 ++++++++++++++++++++++++++ test/src/kex/kex_nist_test.dart | 26 ++++++++++++++++++++++++++ test/src/kex/kex_x25519_test.dart | 13 +++++++++++++ 3 files changed, 65 insertions(+) diff --git a/test/src/kex/kex_dh_test.dart b/test/src/kex/kex_dh_test.dart index 8f9990c..da137a0 100644 --- a/test/src/kex/kex_dh_test.dart +++ b/test/src/kex/kex_dh_test.dart @@ -58,6 +58,32 @@ void main() { expect(computedSecret, equals(isA())); }); }); + + group('SSHKexDH (Async)', () { + test('generate keys and compute shared secret asynchronously (Group 1)', () async { + final kex1 = await SSHKexDH.group1Async(); + final kex2 = await SSHKexDH.group1Async(); + final secret1 = await kex1.computeSecretAsync(kex2.e); + final secret2 = await kex2.computeSecretAsync(kex1.e); + expect(secret1, equals(secret2)); + }); + + test('generate keys and compute shared secret asynchronously (Group 14)', () async { + final kex1 = await SSHKexDH.group14Async(); + final kex2 = await SSHKexDH.group14Async(); + final secret1 = await kex1.computeSecretAsync(kex2.e); + final secret2 = await kex2.computeSecretAsync(kex1.e); + expect(secret1, equals(secret2)); + }); + + test('generate keys and compute shared secret asynchronously (Custom Group)', () async { + final kex1 = await SSHKexDH.createAsync(p: _group1Prime, g: BigInt.from(2), secretBits: 160); + final kex2 = await SSHKexDH.createAsync(p: _group1Prime, g: BigInt.from(2), secretBits: 160); + final secret1 = await kex1.computeSecretAsync(kex2.e); + final secret2 = await kex2.computeSecretAsync(kex1.e); + expect(secret1, equals(secret2)); + }); + }); } // Predefined group 1 prime diff --git a/test/src/kex/kex_nist_test.dart b/test/src/kex/kex_nist_test.dart index ebe24dd..cd6859b 100644 --- a/test/src/kex/kex_nist_test.dart +++ b/test/src/kex/kex_nist_test.dart @@ -67,4 +67,30 @@ void main() { reason: 'Private key should be less than curve order.'); }); }); + + group('SSHKexNist (Async)', () { + test('generate keys and compute shared secret asynchronously (P-256)', () async { + final kex1 = await SSHKexNist.p256Async(); + final kex2 = await SSHKexNist.p256Async(); + final secret1 = await kex1.computeSecretAsync(kex2.publicKey); + final secret2 = await kex2.computeSecretAsync(kex1.publicKey); + expect(secret1, equals(secret2)); + }); + + test('generate keys and compute shared secret asynchronously (P-384)', () async { + final kex1 = await SSHKexNist.p384Async(); + final kex2 = await SSHKexNist.p384Async(); + final secret1 = await kex1.computeSecretAsync(kex2.publicKey); + final secret2 = await kex2.computeSecretAsync(kex1.publicKey); + expect(secret1, equals(secret2)); + }); + + test('generate keys and compute shared secret asynchronously (P-521)', () async { + final kex1 = await SSHKexNist.p521Async(); + final kex2 = await SSHKexNist.p521Async(); + final secret1 = await kex1.computeSecretAsync(kex2.publicKey); + final secret2 = await kex2.computeSecretAsync(kex1.publicKey); + expect(secret1, equals(secret2)); + }); + }); } diff --git a/test/src/kex/kex_x25519_test.dart b/test/src/kex/kex_x25519_test.dart index f6ded39..9794ec9 100644 --- a/test/src/kex/kex_x25519_test.dart +++ b/test/src/kex/kex_x25519_test.dart @@ -61,4 +61,17 @@ void main() { expect(validSharedSecret.bitLength, greaterThan(0)); }); }); + + group('SSHKexX25519 (Async)', () { + test('generate keys and compute shared secret asynchronously', () async { + final kex1 = await SSHKexX25519.createAsync(); + final kex2 = await SSHKexX25519.createAsync(); + expect(kex1.privateKey.length, equals(32)); + expect(kex1.publicKey.length, equals(32)); + + final secret1 = await kex1.computeSecretAsync(kex2.publicKey); + final secret2 = await kex2.computeSecretAsync(kex1.publicKey); + expect(secret1, equals(secret2)); + }); + }); } From d1306bed88bf09e7cc0bf25f600621b675da8ad9 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:13:04 +0200 Subject: [PATCH 8/8] refactor: format KEX test cases for better readability --- test/src/kex/kex_dh_test.dart | 16 +++++++++++----- test/src/kex/kex_nist_test.dart | 9 ++++++--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/test/src/kex/kex_dh_test.dart b/test/src/kex/kex_dh_test.dart index da137a0..e32d193 100644 --- a/test/src/kex/kex_dh_test.dart +++ b/test/src/kex/kex_dh_test.dart @@ -60,7 +60,8 @@ void main() { }); group('SSHKexDH (Async)', () { - test('generate keys and compute shared secret asynchronously (Group 1)', () async { + test('generate keys and compute shared secret asynchronously (Group 1)', + () async { final kex1 = await SSHKexDH.group1Async(); final kex2 = await SSHKexDH.group1Async(); final secret1 = await kex1.computeSecretAsync(kex2.e); @@ -68,7 +69,8 @@ void main() { expect(secret1, equals(secret2)); }); - test('generate keys and compute shared secret asynchronously (Group 14)', () async { + test('generate keys and compute shared secret asynchronously (Group 14)', + () async { final kex1 = await SSHKexDH.group14Async(); final kex2 = await SSHKexDH.group14Async(); final secret1 = await kex1.computeSecretAsync(kex2.e); @@ -76,9 +78,13 @@ void main() { expect(secret1, equals(secret2)); }); - test('generate keys and compute shared secret asynchronously (Custom Group)', () async { - final kex1 = await SSHKexDH.createAsync(p: _group1Prime, g: BigInt.from(2), secretBits: 160); - final kex2 = await SSHKexDH.createAsync(p: _group1Prime, g: BigInt.from(2), secretBits: 160); + test( + 'generate keys and compute shared secret asynchronously (Custom Group)', + () async { + final kex1 = await SSHKexDH.createAsync( + p: _group1Prime, g: BigInt.from(2), secretBits: 160); + final kex2 = await SSHKexDH.createAsync( + p: _group1Prime, g: BigInt.from(2), secretBits: 160); final secret1 = await kex1.computeSecretAsync(kex2.e); final secret2 = await kex2.computeSecretAsync(kex1.e); expect(secret1, equals(secret2)); diff --git a/test/src/kex/kex_nist_test.dart b/test/src/kex/kex_nist_test.dart index cd6859b..9205114 100644 --- a/test/src/kex/kex_nist_test.dart +++ b/test/src/kex/kex_nist_test.dart @@ -69,7 +69,8 @@ void main() { }); group('SSHKexNist (Async)', () { - test('generate keys and compute shared secret asynchronously (P-256)', () async { + test('generate keys and compute shared secret asynchronously (P-256)', + () async { final kex1 = await SSHKexNist.p256Async(); final kex2 = await SSHKexNist.p256Async(); final secret1 = await kex1.computeSecretAsync(kex2.publicKey); @@ -77,7 +78,8 @@ void main() { expect(secret1, equals(secret2)); }); - test('generate keys and compute shared secret asynchronously (P-384)', () async { + test('generate keys and compute shared secret asynchronously (P-384)', + () async { final kex1 = await SSHKexNist.p384Async(); final kex2 = await SSHKexNist.p384Async(); final secret1 = await kex1.computeSecretAsync(kex2.publicKey); @@ -85,7 +87,8 @@ void main() { expect(secret1, equals(secret2)); }); - test('generate keys and compute shared secret asynchronously (P-521)', () async { + test('generate keys and compute shared secret asynchronously (P-521)', + () async { final kex1 = await SSHKexNist.p521Async(); final kex2 = await SSHKexNist.p521Async(); final secret1 = await kex1.computeSecretAsync(kex2.publicKey);