diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d0b7f..772ed6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [2.18.0] - 2026-05-18 +- **BREAKING**: `SSHHostkeyVerifyHandler` now receives an OpenSSH-style `SHA256:` host key fingerprint instead of the previous raw MD5 digest, so host key pinning code must be updated accordingly [#162]. Thanks [@thyssentishman]. + ## [2.17.1] - 2026-04-12 - Made `SSHPem.decode` accept CRLF (`\r\n`) line endings in addition to LF when parsing PEM content [#157]. Thanks [@gkc]. diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index d66a245..815885e 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -31,12 +31,19 @@ typedef SSHPrintHandler = void Function(String?); /// Function called when host key is received. /// [type] is the type of the host key, For example 'ssh-rsa', -/// [fingerprint] md5 fingerprint of the host key. +/// [fingerprint] OpenSSH-style SHA256 fingerprint of the host key, +/// UTF-8 encoded as `SHA256:`. typedef SSHHostkeyVerifyHandler = FutureOr Function( String type, Uint8List fingerprint, ); +Uint8List _hostkeyFingerprint(Uint8List hostkey) { + final fingerprint = SHA256Digest().process(hostkey); + final encoded = base64.encode(fingerprint).replaceAll('=', ''); + return Uint8List.fromList(utf8.encode('SHA256:$encoded')); +} + typedef SSHTransportReadyHandler = void Function(); typedef SSHPacketHandler = void Function(Uint8List payload); @@ -1180,7 +1187,7 @@ class SSHTransport { _sessionId ??= exchangeHash; _sharedSecret = sharedSecret; - final fingerprint = MD5Digest().process(hostkey); + final fingerprint = _hostkeyFingerprint(hostkey); if (_hostkeyVerified) { _sendNewKeys(); diff --git a/pubspec.yaml b/pubspec.yaml index b48b3a4..8b15ed9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.17.1 +version: 2.18.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/ssh_client_test.dart b/test/src/ssh_client_test.dart index 4db35e1..555ce4e 100644 --- a/test/src/ssh_client_test.dart +++ b/test/src/ssh_client_test.dart @@ -16,6 +16,56 @@ void main() { client.close(); }); + test('onVerifyHostKey is called with OpenSSH-style SHA256 fingerprint', + () async { + var verifyCalled = false; + String? hostkeyType; + String? hostkeyFingerprint; + + var client = SSHClient( + await SSHSocket.connect('test.rebex.net', 22), + username: 'demo', + onPasswordRequest: () => 'password', + onVerifyHostKey: (type, fingerprint) { + verifyCalled = true; + hostkeyType = type; + hostkeyFingerprint = utf8.decode(fingerprint); + return true; + }, + ); + + await client.authenticated; + client.close(); + + expect(verifyCalled, isTrue); + expect(hostkeyType, isNotEmpty); + expect(hostkeyFingerprint, startsWith('SHA256:')); + final base64Part = hostkeyFingerprint!.substring(7); + expect(base64Part.length, equals(43)); + expect(() => base64.decode('$base64Part='), returnsNormally); + }); + + test('onVerifyHostKey returning false aborts connection', () async { + var client = SSHClient( + await SSHSocket.connect('test.rebex.net', 22), + username: 'demo', + onPasswordRequest: () => 'password', + onVerifyHostKey: (type, fingerprint) { + return false; + }, + ); + + try { + await client.authenticated; + fail('should have thrown'); + } catch (e) { + expect(e, isA()); + expect((e as SSHAuthAbortError).reason, isA()); + } finally { + client.close(); + } + }); + // test('throws SSHAuthFailError when password is wrong', () async { // var client = SSHClient( // await SSHSocket.connect('test.rebex.net', 22), @@ -142,7 +192,8 @@ void main() { fail('should have thrown'); } catch (e) { expect(e, isA()); - expect((e as SSHAuthAbortError).reason!, isA()); + expect((e as SSHAuthAbortError).reason, + anyOf(isNull, isA())); } client.close(); diff --git a/test/src/ssh_transport_fingerprint_test.dart b/test/src/ssh_transport_fingerprint_test.dart new file mode 100644 index 0000000..9415010 --- /dev/null +++ b/test/src/ssh_transport_fingerprint_test.dart @@ -0,0 +1,29 @@ +import 'dart:convert'; +import 'dart:mirrors'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:pointycastle/export.dart'; +import 'package:test/test.dart'; + +void main() { + final transportLibrary = reflectClass(SSHTransport).owner as LibraryMirror; + + Uint8List invokeFingerprint(Uint8List hostkey) { + final symbol = + MirrorSystem.getSymbol('_hostkeyFingerprint', transportLibrary); + return transportLibrary.invoke(symbol, [hostkey]).reflectee as Uint8List; + } + + test('formats host key fingerprints using OpenSSH SHA256 style', () { + final hostkey = + Uint8List.fromList(List.generate(32, (index) => index)); + + final fingerprint = utf8.decode(invokeFingerprint(hostkey)); + final expectedDigest = SHA256Digest().process(hostkey); + final expected = + 'SHA256:${base64.encode(expectedDigest).replaceAll('=', '')}'; + + expect(fingerprint, equals(expected)); + }); +} diff --git a/test/test_utils.dart b/test/test_utils.dart index 328fe96..68b26e9 100644 --- a/test/test_utils.dart +++ b/test/test_utils.dart @@ -43,7 +43,9 @@ Future> getTestKeyPairs() async { /// /// The path is relative to the test/fixtures directory. String fixture(String path) { - return File('test/fixtures/$path').readAsStringSync(); + return File('test/fixtures/$path') + .readAsStringSync() + .replaceAll('\r\n', '\n'); } /// Create a [SSH_Message_Channel_Close] message.