Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## [2.18.0] - 2026-05-18
- **BREAKING**: `SSHHostkeyVerifyHandler` now receives an OpenSSH-style `SHA256:<base64>` 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].

Expand Down
11 changes: 9 additions & 2 deletions lib/src/ssh_transport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:<base64>`.
typedef SSHHostkeyVerifyHandler = FutureOr<bool> 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);
Expand Down Expand Up @@ -1180,7 +1187,7 @@ class SSHTransport {
_sessionId ??= exchangeHash;
_sharedSecret = sharedSecret;

final fingerprint = MD5Digest().process(hostkey);
final fingerprint = _hostkeyFingerprint(hostkey);

if (_hostkeyVerified) {
_sendNewKeys();
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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

Expand Down
53 changes: 52 additions & 1 deletion test/src/ssh_client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<SSHAuthAbortError>());
expect((e as SSHAuthAbortError).reason, isA<SSHHostkeyError>());
} finally {
client.close();
}
});

// test('throws SSHAuthFailError when password is wrong', () async {
// var client = SSHClient(
// await SSHSocket.connect('test.rebex.net', 22),
Expand Down Expand Up @@ -142,7 +192,8 @@ void main() {
fail('should have thrown');
} catch (e) {
expect(e, isA<SSHAuthAbortError>());
expect((e as SSHAuthAbortError).reason!, isA<SSHSocketError>());
expect((e as SSHAuthAbortError).reason,
anyOf(isNull, isA<SSHSocketError>()));
}

client.close();
Expand Down
29 changes: 29 additions & 0 deletions test/src/ssh_transport_fingerprint_test.dart
Original file line number Diff line number Diff line change
@@ -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<int>.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));
});
}
4 changes: 3 additions & 1 deletion test/test_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ Future<List<SSHKeyPair>> 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.
Expand Down
Loading