From 49b9d2f972745fbd2dae5dbca2754c4f423b213e Mon Sep 17 00:00:00 2001 From: GT610 Date: Sat, 19 Sep 2026 23:40:04 +0800 Subject: [PATCH 1/5] feat(ssh): let a server negotiate the algorithms SSH has retired dartssh2 proposes a modern-only set, and since the hardening in #1318 the retired algorithms are not in it at all. A daemon that predates RFC 8332 - a router's dropbear, a switch - advertises only the SHA-1 ssh-rsa host key, so the handshake ends at host-key negotiation with 'No matching host key algorithm', before authentication is attempted (#1490). Add SshCredential.allowLegacyAlgorithms and SshAlgorithms.legacy, which appends the retired algorithms after the modern ones so a host offering anything current still negotiates it and only one with nothing else falls through. Off by default; stored in a new server.ssh_allow_legacy_algorithms column (schema v27). --- lib/core/utils/server.dart | 3 + lib/core/utils/ssh_algorithms.dart | 68 +++++++++++ lib/data/model/server/ssh_credential.dart | 22 +++- lib/data/model/server/ssh_credential.g.dart | 2 + lib/data/provider/server/all.g.dart | 2 +- lib/data/provider/server/single.g.dart | 2 +- lib/data/store/db.dart | 7 ++ lib/data/store/db.g.dart | 91 +++++++++++++++ lib/data/store/migrations/all.dart | 2 + .../m026_ssh_legacy_algorithms.dart | 39 +++++++ lib/data/store/schema.dart | 6 +- lib/data/store/server.dart | 6 + .../m025_transport_switches_test.dart | 14 ++- .../m026_ssh_legacy_algorithms_test.dart | 107 ++++++++++++++++++ test/unit/ssh/ssh_algorithms_test.dart | 91 +++++++++++++++ .../ssh/ssh_credential_equality_test.dart | 15 +++ 16 files changed, 469 insertions(+), 8 deletions(-) create mode 100644 lib/core/utils/ssh_algorithms.dart create mode 100644 lib/data/store/migrations/m026_ssh_legacy_algorithms.dart create mode 100644 test/migration/m026_ssh_legacy_algorithms_test.dart create mode 100644 test/unit/ssh/ssh_algorithms_test.dart diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 84cfeb5b2d..b8fd8c3273 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -10,6 +10,7 @@ import 'package:server_box/core/app_navigator.dart'; import 'package:server_box/core/diag.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/utils/proxy_command_socket.dart'; +import 'package:server_box/core/utils/ssh_algorithms.dart'; import 'package:server_box/core/utils/ssh_auth.dart'; import 'package:server_box/core/utils/ssh_config.dart'; import 'package:server_box/core/utils/ssh_key_unlock.dart'; @@ -662,6 +663,7 @@ Future _authenticatedClient({ ? null : (request) => onKeyboardInteractive(spi, request), onVerifyHostKey: hostKeyVerifier.call, + algorithms: SshAlgorithms.of(ssh), handshakeTimeout: timeout, authTimeout: timeout, ); @@ -752,6 +754,7 @@ Future _authenticatedClient({ ? null : (request) => onKeyboardInteractive(spi, request), onVerifyHostKey: hostKeyVerifier.call, + algorithms: SshAlgorithms.of(ssh), handshakeTimeout: timeout, authTimeout: timeout, ); diff --git a/lib/core/utils/ssh_algorithms.dart b/lib/core/utils/ssh_algorithms.dart new file mode 100644 index 0000000000..b68c51871e --- /dev/null +++ b/lib/core/utils/ssh_algorithms.dart @@ -0,0 +1,68 @@ +import 'package:dartssh2/dartssh2.dart'; +import 'package:server_box/data/model/server/ssh_credential.dart'; + +/// The algorithm sets this app proposes to a server. +/// +/// dartssh2's default is modern-only. RSA host keys are still offered, but only +/// under the RFC 8332 spellings (`rsa-sha2-256`, `rsa-sha2-512`); the SHA-1 +/// `ssh-rsa` name it replaced, and the SHA-1 key exchanges, CBC ciphers and +/// SHA-1/MD5 MACs around it, are not in the list at all. That is the right +/// default for anything current, and it is what makes a server that predates +/// those names fail before it can authenticate: +/// +/// ```text +/// SSHAuthAbortError(... reason: SSHInternalError( +/// Bad state: No matching host key algorithm)) +/// ``` +/// +/// A router's dropbear, a managed switch, an old embedded appliance — the +/// machine in front of the user is the only place that answer can be known, and +/// it cannot be probed for without answering the second failure behind it, so +/// [SshCredential.allowLegacyAlgorithms] is a per-server choice. +/// +/// The retired algorithms are appended *after* the modern ones rather than +/// replacing them, so even on an opted-in host a server that offers anything +/// current still negotiates it. What they cannot do is tell "this daemon has +/// nothing newer" from "an attacker removed everything newer from the list": +/// KEXINIT is unauthenticated, so enabling this gives up that much. That is why +/// it is opt-in, per host, and off everywhere else. +abstract final class SshAlgorithms { + /// What to propose for [ssh]. + static SSHAlgorithms of(SshCredential ssh) => + ssh.allowLegacyAlgorithms ? legacy : const SSHAlgorithms(); + + /// The defaults with the algorithms SSH has retired appended. + /// + /// Built from a default instance rather than a copied literal, so a change to + /// the fork's list moves this with it and the two cannot drift apart — the + /// only thing this adds is the tail, and the order of the tail is the order + /// the fork itself last proposed these in. + static final SSHAlgorithms legacy = _withLegacyAlgorithms(); +} + +SSHAlgorithms _withLegacyAlgorithms() { + const modern = SSHAlgorithms(); + return SSHAlgorithms( + kex: [ + ...modern.kex, + // Group-exchange SHA-1 first: some old daemons offer it and not the fixed + // groups, and having any group it accepts is what moves past the kex. + SSHKexType.dhGexSha1, + SSHKexType.dh14Sha1, + SSHKexType.dh1Sha1, + ], + hostkey: [...modern.hostkey, SSHHostkeyType.rsaSha1], + cipher: [ + ...modern.cipher, + SSHCipherType.aes256cbc, + SSHCipherType.aes128cbc, + ], + mac: [ + ...modern.mac, + SSHMacType.hmacSha1, + SSHMacType.hmacMd5, + SSHMacType.hmacSha256_96, + SSHMacType.hmacSha512_96, + ], + ); +} diff --git a/lib/data/model/server/ssh_credential.dart b/lib/data/model/server/ssh_credential.dart index e95e2af0bf..a5831fe321 100644 --- a/lib/data/model/server/ssh_credential.dart +++ b/lib/data/model/server/ssh_credential.dart @@ -92,6 +92,18 @@ final class SshCredential { ) final SshFileTransport fileTransport; + /// Whether this host may negotiate the algorithms SSH has retired. + /// + /// Defaulted false, so every record written before this existed goes on + /// proposing exactly what it did. An old SSH daemon — a router's dropbear, a + /// switch — often advertises only the SHA-1 `ssh-rsa` host key spelling, + /// which the modern set no longer contains, and the handshake then dies at + /// host-key negotiation with `No matching host key algorithm`, before any + /// authentication is attempted. Turning this on appends the retired + /// algorithms *after* the modern ones, so a host that offers anything current + /// still negotiates it and only one with nothing else falls through. + final bool allowLegacyAlgorithms; + /// Carry the SSH byte stream over this server's `monitor` agent instead of /// connecting to [ip]:[port] directly, for hosts whose SSH port isn't /// reachable but whose monitor endpoint is. @@ -114,6 +126,7 @@ final class SshCredential { this.jumpIds, this.proxyCommand, this.fileTransport = SshFileTransport.sftp, + this.allowLegacyAlgorithms = false, }); factory SshCredential.fromJson(Map json) => @@ -235,6 +248,7 @@ final class SshCredential { Object? jumpIds = _unset, Object? proxyCommand = _unset, SshFileTransport? fileTransport, + bool? allowLegacyAlgorithms, }) { return SshCredential( ip: ip ?? this.ip, @@ -253,6 +267,8 @@ final class SshCredential { ? this.proxyCommand : proxyCommand as String?, fileTransport: fileTransport ?? this.fileTransport, + allowLegacyAlgorithms: + allowLegacyAlgorithms ?? this.allowLegacyAlgorithms, ); } @@ -268,7 +284,10 @@ final class SshCredential { proxyCommand == other.proxyCommand && // Changing how the socket is obtained needs a reconnect just as much // as changing the address does - listEquals(resolvedJumpIds, other.resolvedJumpIds); + listEquals(resolvedJumpIds, other.resolvedJumpIds) && + // The algorithms are chosen once, in the handshake that is already + // over, so a finished session cannot be moved onto a different set. + allowLegacyAlgorithms == other.allowLegacyAlgorithms; } @override @@ -302,6 +321,7 @@ final class SshCredential { Object.hashAll(resolvedJumpIds), proxyCommand, fileTransport, + allowLegacyAlgorithms, ); } diff --git a/lib/data/model/server/ssh_credential.g.dart b/lib/data/model/server/ssh_credential.g.dart index dd1c254911..daa18c1ca4 100644 --- a/lib/data/model/server/ssh_credential.g.dart +++ b/lib/data/model/server/ssh_credential.g.dart @@ -30,6 +30,7 @@ SshCredential _$SshCredentialFromJson(Map json) => unknownValue: SshFileTransport.sftp, ) ?? SshFileTransport.sftp, + allowLegacyAlgorithms: json['allowLegacyAlgorithms'] as bool? ?? false, ); Map _$SshCredentialToJson(SshCredential instance) => @@ -46,6 +47,7 @@ Map _$SshCredentialToJson(SshCredential instance) => 'jumpIds': ?instance.jumpIds, 'proxyCommand': ?instance.proxyCommand, 'fileTransport': _$SshFileTransportEnumMap[instance.fileTransport]!, + 'allowLegacyAlgorithms': instance.allowLegacyAlgorithms, }; const _$SshFileTransportEnumMap = { diff --git a/lib/data/provider/server/all.g.dart b/lib/data/provider/server/all.g.dart index bcca0a4094..e46dc584e8 100644 --- a/lib/data/provider/server/all.g.dart +++ b/lib/data/provider/server/all.g.dart @@ -41,7 +41,7 @@ final class ServersNotifierProvider } } -String _$serversNotifierHash() => r'9f2cfc71c89a491ec21c0781cc3f1e98e647091b'; +String _$serversNotifierHash() => r'f7cfe2e3e38ee2fefda197908b12a985ba6f7a11'; abstract class _$ServersNotifier extends $Notifier { ServersState build(); diff --git a/lib/data/provider/server/single.g.dart b/lib/data/provider/server/single.g.dart index 9f5d4b1a33..5ff80378aa 100644 --- a/lib/data/provider/server/single.g.dart +++ b/lib/data/provider/server/single.g.dart @@ -58,7 +58,7 @@ final class ServerNotifierProvider } } -String _$serverNotifierHash() => r'1f1f2aff8b5f4de8e4264c7030f7e5ffbfbf6b9e'; +String _$serverNotifierHash() => r'754ea800c69a95bead926a65bc065c5f1a5905d3'; final class ServerNotifierFamily extends $Family with diff --git a/lib/data/store/db.dart b/lib/data/store/db.dart index 3c53942349..7d595b7997 100644 --- a/lib/data/store/db.dart +++ b/lib/data/store/db.dart @@ -121,6 +121,13 @@ class Servers extends Table with SyncMeta { /// written before the column existed meant — see `m014`. TextColumn get sshFileTransport => text().nullable()(); + /// Whether this host may negotiate the algorithms SSH has retired — the + /// SHA-1 `ssh-rsa` host key spelling and its neighbours. False for every row + /// written before the column, which is what those builds proposed; see + /// [SshCredential.allowLegacyAlgorithms]. + BoolColumn get sshAllowLegacyAlgorithms => + boolean().withDefault(const Constant(false))(); + /// Which way of reaching this server is tried first, by /// `ServerTransport.name`. Null means "whichever is configured", which is /// the only answer for a server that has just one — and the only shape rows diff --git a/lib/data/store/db.g.dart b/lib/data/store/db.g.dart index 43ab50c79a..5208ba8486 100644 --- a/lib/data/store/db.g.dart +++ b/lib/data/store/db.g.dart @@ -959,6 +959,21 @@ class $ServersTable extends Servers with TableInfo<$ServersTable, ServerRow> { type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _sshAllowLegacyAlgorithmsMeta = + const VerificationMeta('sshAllowLegacyAlgorithms'); + @override + late final GeneratedColumn sshAllowLegacyAlgorithms = + GeneratedColumn( + 'ssh_allow_legacy_algorithms', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("ssh_allow_legacy_algorithms" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); static const VerificationMeta _preferredTransportMeta = const VerificationMeta('preferredTransport'); @override @@ -1250,6 +1265,7 @@ class $ServersTable extends Servers with TableInfo<$ServersTable, ServerRow> { sshAlterUrl, sshProxyCommand, sshFileTransport, + sshAllowLegacyAlgorithms, preferredTransport, sshEnabled, monitorEnabled, @@ -1393,6 +1409,15 @@ class $ServersTable extends Servers with TableInfo<$ServersTable, ServerRow> { ), ); } + if (data.containsKey('ssh_allow_legacy_algorithms')) { + context.handle( + _sshAllowLegacyAlgorithmsMeta, + sshAllowLegacyAlgorithms.isAcceptableOrUnknown( + data['ssh_allow_legacy_algorithms']!, + _sshAllowLegacyAlgorithmsMeta, + ), + ); + } if (data.containsKey('preferred_transport')) { context.handle( _preferredTransportMeta, @@ -1636,6 +1661,10 @@ class $ServersTable extends Servers with TableInfo<$ServersTable, ServerRow> { DriftSqlType.string, data['${effectivePrefix}ssh_file_transport'], ), + sshAllowLegacyAlgorithms: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}ssh_allow_legacy_algorithms'], + )!, preferredTransport: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}preferred_transport'], @@ -1767,6 +1796,12 @@ class ServerRow extends DataClass implements Insertable { /// written before the column existed meant — see `m014`. final String? sshFileTransport; + /// Whether this host may negotiate the algorithms SSH has retired — the + /// SHA-1 `ssh-rsa` host key spelling and its neighbours. False for every row + /// written before the column, which is what those builds proposed; see + /// [SshCredential.allowLegacyAlgorithms]. + final bool sshAllowLegacyAlgorithms; + /// Which way of reaching this server is tried first, by /// `ServerTransport.name`. Null means "whichever is configured", which is /// the only answer for a server that has just one — and the only shape rows @@ -1846,6 +1881,7 @@ class ServerRow extends DataClass implements Insertable { this.sshAlterUrl, this.sshProxyCommand, this.sshFileTransport, + required this.sshAllowLegacyAlgorithms, this.preferredTransport, required this.sshEnabled, required this.monitorEnabled, @@ -1909,6 +1945,9 @@ class ServerRow extends DataClass implements Insertable { if (!nullToAbsent || sshFileTransport != null) { map['ssh_file_transport'] = Variable(sshFileTransport); } + map['ssh_allow_legacy_algorithms'] = Variable( + sshAllowLegacyAlgorithms, + ); if (!nullToAbsent || preferredTransport != null) { map['preferred_transport'] = Variable(preferredTransport); } @@ -2013,6 +2052,7 @@ class ServerRow extends DataClass implements Insertable { sshFileTransport: sshFileTransport == null && nullToAbsent ? const Value.absent() : Value(sshFileTransport), + sshAllowLegacyAlgorithms: Value(sshAllowLegacyAlgorithms), preferredTransport: preferredTransport == null && nullToAbsent ? const Value.absent() : Value(preferredTransport), @@ -2101,6 +2141,9 @@ class ServerRow extends DataClass implements Insertable { sshAlterUrl: serializer.fromJson(json['sshAlterUrl']), sshProxyCommand: serializer.fromJson(json['sshProxyCommand']), sshFileTransport: serializer.fromJson(json['sshFileTransport']), + sshAllowLegacyAlgorithms: serializer.fromJson( + json['sshAllowLegacyAlgorithms'], + ), preferredTransport: serializer.fromJson( json['preferredTransport'], ), @@ -2150,6 +2193,9 @@ class ServerRow extends DataClass implements Insertable { 'sshAlterUrl': serializer.toJson(sshAlterUrl), 'sshProxyCommand': serializer.toJson(sshProxyCommand), 'sshFileTransport': serializer.toJson(sshFileTransport), + 'sshAllowLegacyAlgorithms': serializer.toJson( + sshAllowLegacyAlgorithms, + ), 'preferredTransport': serializer.toJson(preferredTransport), 'sshEnabled': serializer.toJson(sshEnabled), 'monitorEnabled': serializer.toJson(monitorEnabled), @@ -2193,6 +2239,7 @@ class ServerRow extends DataClass implements Insertable { Value sshAlterUrl = const Value.absent(), Value sshProxyCommand = const Value.absent(), Value sshFileTransport = const Value.absent(), + bool? sshAllowLegacyAlgorithms, Value preferredTransport = const Value.absent(), bool? sshEnabled, bool? monitorEnabled, @@ -2237,6 +2284,8 @@ class ServerRow extends DataClass implements Insertable { sshFileTransport: sshFileTransport.present ? sshFileTransport.value : this.sshFileTransport, + sshAllowLegacyAlgorithms: + sshAllowLegacyAlgorithms ?? this.sshAllowLegacyAlgorithms, preferredTransport: preferredTransport.present ? preferredTransport.value : this.preferredTransport, @@ -2301,6 +2350,9 @@ class ServerRow extends DataClass implements Insertable { sshFileTransport: data.sshFileTransport.present ? data.sshFileTransport.value : this.sshFileTransport, + sshAllowLegacyAlgorithms: data.sshAllowLegacyAlgorithms.present + ? data.sshAllowLegacyAlgorithms.value + : this.sshAllowLegacyAlgorithms, preferredTransport: data.preferredTransport.present ? data.preferredTransport.value : this.preferredTransport, @@ -2370,6 +2422,7 @@ class ServerRow extends DataClass implements Insertable { ..write('sshAlterUrl: $sshAlterUrl, ') ..write('sshProxyCommand: $sshProxyCommand, ') ..write('sshFileTransport: $sshFileTransport, ') + ..write('sshAllowLegacyAlgorithms: $sshAllowLegacyAlgorithms, ') ..write('preferredTransport: $preferredTransport, ') ..write('sshEnabled: $sshEnabled, ') ..write('monitorEnabled: $monitorEnabled, ') @@ -2415,6 +2468,7 @@ class ServerRow extends DataClass implements Insertable { sshAlterUrl, sshProxyCommand, sshFileTransport, + sshAllowLegacyAlgorithms, preferredTransport, sshEnabled, monitorEnabled, @@ -2459,6 +2513,7 @@ class ServerRow extends DataClass implements Insertable { other.sshAlterUrl == this.sshAlterUrl && other.sshProxyCommand == this.sshProxyCommand && other.sshFileTransport == this.sshFileTransport && + other.sshAllowLegacyAlgorithms == this.sshAllowLegacyAlgorithms && other.preferredTransport == this.preferredTransport && other.sshEnabled == this.sshEnabled && other.monitorEnabled == this.monitorEnabled && @@ -2501,6 +2556,7 @@ class ServersCompanion extends UpdateCompanion { final Value sshAlterUrl; final Value sshProxyCommand; final Value sshFileTransport; + final Value sshAllowLegacyAlgorithms; final Value preferredTransport; final Value sshEnabled; final Value monitorEnabled; @@ -2541,6 +2597,7 @@ class ServersCompanion extends UpdateCompanion { this.sshAlterUrl = const Value.absent(), this.sshProxyCommand = const Value.absent(), this.sshFileTransport = const Value.absent(), + this.sshAllowLegacyAlgorithms = const Value.absent(), this.preferredTransport = const Value.absent(), this.sshEnabled = const Value.absent(), this.monitorEnabled = const Value.absent(), @@ -2582,6 +2639,7 @@ class ServersCompanion extends UpdateCompanion { this.sshAlterUrl = const Value.absent(), this.sshProxyCommand = const Value.absent(), this.sshFileTransport = const Value.absent(), + this.sshAllowLegacyAlgorithms = const Value.absent(), this.preferredTransport = const Value.absent(), this.sshEnabled = const Value.absent(), this.monitorEnabled = const Value.absent(), @@ -2624,6 +2682,7 @@ class ServersCompanion extends UpdateCompanion { Expression? sshAlterUrl, Expression? sshProxyCommand, Expression? sshFileTransport, + Expression? sshAllowLegacyAlgorithms, Expression? preferredTransport, Expression? sshEnabled, Expression? monitorEnabled, @@ -2665,6 +2724,8 @@ class ServersCompanion extends UpdateCompanion { if (sshAlterUrl != null) 'ssh_alter_url': sshAlterUrl, if (sshProxyCommand != null) 'ssh_proxy_command': sshProxyCommand, if (sshFileTransport != null) 'ssh_file_transport': sshFileTransport, + if (sshAllowLegacyAlgorithms != null) + 'ssh_allow_legacy_algorithms': sshAllowLegacyAlgorithms, if (preferredTransport != null) 'preferred_transport': preferredTransport, if (sshEnabled != null) 'ssh_enabled': sshEnabled, if (monitorEnabled != null) 'monitor_enabled': monitorEnabled, @@ -2709,6 +2770,7 @@ class ServersCompanion extends UpdateCompanion { Value? sshAlterUrl, Value? sshProxyCommand, Value? sshFileTransport, + Value? sshAllowLegacyAlgorithms, Value? preferredTransport, Value? sshEnabled, Value? monitorEnabled, @@ -2750,6 +2812,8 @@ class ServersCompanion extends UpdateCompanion { sshAlterUrl: sshAlterUrl ?? this.sshAlterUrl, sshProxyCommand: sshProxyCommand ?? this.sshProxyCommand, sshFileTransport: sshFileTransport ?? this.sshFileTransport, + sshAllowLegacyAlgorithms: + sshAllowLegacyAlgorithms ?? this.sshAllowLegacyAlgorithms, preferredTransport: preferredTransport ?? this.preferredTransport, sshEnabled: sshEnabled ?? this.sshEnabled, monitorEnabled: monitorEnabled ?? this.monitorEnabled, @@ -2825,6 +2889,11 @@ class ServersCompanion extends UpdateCompanion { if (sshFileTransport.present) { map['ssh_file_transport'] = Variable(sshFileTransport.value); } + if (sshAllowLegacyAlgorithms.present) { + map['ssh_allow_legacy_algorithms'] = Variable( + sshAllowLegacyAlgorithms.value, + ); + } if (preferredTransport.present) { map['preferred_transport'] = Variable(preferredTransport.value); } @@ -2920,6 +2989,7 @@ class ServersCompanion extends UpdateCompanion { ..write('sshAlterUrl: $sshAlterUrl, ') ..write('sshProxyCommand: $sshProxyCommand, ') ..write('sshFileTransport: $sshFileTransport, ') + ..write('sshAllowLegacyAlgorithms: $sshAllowLegacyAlgorithms, ') ..write('preferredTransport: $preferredTransport, ') ..write('sshEnabled: $sshEnabled, ') ..write('monitorEnabled: $monitorEnabled, ') @@ -10306,6 +10376,7 @@ typedef $$ServersTableCreateCompanionBuilder = Value sshAlterUrl, Value sshProxyCommand, Value sshFileTransport, + Value sshAllowLegacyAlgorithms, Value preferredTransport, Value sshEnabled, Value monitorEnabled, @@ -10348,6 +10419,7 @@ typedef $$ServersTableUpdateCompanionBuilder = Value sshAlterUrl, Value sshProxyCommand, Value sshFileTransport, + Value sshAllowLegacyAlgorithms, Value preferredTransport, Value sshEnabled, Value monitorEnabled, @@ -10742,6 +10814,11 @@ class $$ServersTableFilterComposer extends Composer<_$AppDb, $ServersTable> { builder: (column) => ColumnFilters(column), ); + ColumnFilters get sshAllowLegacyAlgorithms => $composableBuilder( + column: $table.sshAllowLegacyAlgorithms, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get preferredTransport => $composableBuilder( column: $table.preferredTransport, builder: (column) => ColumnFilters(column), @@ -11308,6 +11385,11 @@ class $$ServersTableOrderingComposer extends Composer<_$AppDb, $ServersTable> { builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get sshAllowLegacyAlgorithms => $composableBuilder( + column: $table.sshAllowLegacyAlgorithms, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get preferredTransport => $composableBuilder( column: $table.preferredTransport, builder: (column) => ColumnOrderings(column), @@ -11533,6 +11615,11 @@ class $$ServersTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get sshAllowLegacyAlgorithms => $composableBuilder( + column: $table.sshAllowLegacyAlgorithms, + builder: (column) => column, + ); + GeneratedColumn get preferredTransport => $composableBuilder( column: $table.preferredTransport, builder: (column) => column, @@ -12060,6 +12147,7 @@ class $$ServersTableTableManager Value sshAlterUrl = const Value.absent(), Value sshProxyCommand = const Value.absent(), Value sshFileTransport = const Value.absent(), + Value sshAllowLegacyAlgorithms = const Value.absent(), Value preferredTransport = const Value.absent(), Value sshEnabled = const Value.absent(), Value monitorEnabled = const Value.absent(), @@ -12100,6 +12188,7 @@ class $$ServersTableTableManager sshAlterUrl: sshAlterUrl, sshProxyCommand: sshProxyCommand, sshFileTransport: sshFileTransport, + sshAllowLegacyAlgorithms: sshAllowLegacyAlgorithms, preferredTransport: preferredTransport, sshEnabled: sshEnabled, monitorEnabled: monitorEnabled, @@ -12142,6 +12231,7 @@ class $$ServersTableTableManager Value sshAlterUrl = const Value.absent(), Value sshProxyCommand = const Value.absent(), Value sshFileTransport = const Value.absent(), + Value sshAllowLegacyAlgorithms = const Value.absent(), Value preferredTransport = const Value.absent(), Value sshEnabled = const Value.absent(), Value monitorEnabled = const Value.absent(), @@ -12182,6 +12272,7 @@ class $$ServersTableTableManager sshAlterUrl: sshAlterUrl, sshProxyCommand: sshProxyCommand, sshFileTransport: sshFileTransport, + sshAllowLegacyAlgorithms: sshAllowLegacyAlgorithms, preferredTransport: preferredTransport, sshEnabled: sshEnabled, monitorEnabled: monitorEnabled, diff --git a/lib/data/store/migrations/all.dart b/lib/data/store/migrations/all.dart index be3ecbb6a4..2d9c063a6a 100644 --- a/lib/data/store/migrations/all.dart +++ b/lib/data/store/migrations/all.dart @@ -20,6 +20,7 @@ import 'package:server_box/data/store/migrations/m022_ai_endpoint_version.dart'; import 'package:server_box/data/store/migrations/m023_enum_names.dart'; import 'package:server_box/data/store/migrations/m024_remote_desktop_profiles.dart'; import 'package:server_box/data/store/migrations/m025_transport_switches.dart'; +import 'package:server_box/data/store/migrations/m026_ssh_legacy_algorithms.dart'; import 'package:server_box/data/store/schema.dart'; /// Every migration, ordered, in the one place that names them. @@ -62,4 +63,5 @@ const kSchemaMigrations = [ EnumNamesMigration(), RemoteDesktopProfilesMigration(), TransportSwitchesMigration(), + SshLegacyAlgorithmsMigration(), ]; diff --git a/lib/data/store/migrations/m026_ssh_legacy_algorithms.dart b/lib/data/store/migrations/m026_ssh_legacy_algorithms.dart new file mode 100644 index 0000000000..ae0705c12a --- /dev/null +++ b/lib/data/store/migrations/m026_ssh_legacy_algorithms.dart @@ -0,0 +1,39 @@ +import 'package:fl_lib/fl_lib.dart'; +import 'package:server_box/data/store/schema.dart'; + +/// Adds `server.ssh_allow_legacy_algorithms`: whether this host may negotiate +/// the algorithms SSH has retired. +/// +/// False for every existing row, which is what those builds proposed and what +/// the modern algorithm set still proposes. The column exists only so a host +/// that cannot be reached without `ssh-rsa` or a SHA-1 key exchange can be +/// opted in by hand; nothing changes about how any server was already being +/// talked to. +/// +/// Written by hand rather than left to Drift, which owns the DDL but only for a +/// database being *created*: an install already past this step has a `server` +/// table Drift will not revisit, and `createTables` is `IF NOT EXISTS` +/// throughout. `m026_ssh_legacy_algorithms_test.dart` is what checks the two +/// agree. +class SshLegacyAlgorithmsMigration implements SchemaMigration { + const SshLegacyAlgorithmsMigration(); + + @override + int get from => 26; + + @override + Future apply() async { + final db = SqliteDb.instance; + final columns = db + .select('PRAGMA table_info(server);') + .map((row) => row['name'] as String) + .toSet(); + // Guarded, so the step is safe to run again after a process stops partway: + // the version is recorded only once every statement has run. + if (columns.contains('ssh_allow_legacy_algorithms')) return; + db.execute( + 'ALTER TABLE server ADD COLUMN ssh_allow_legacy_algorithms ' + 'INTEGER NOT NULL DEFAULT 0;', + ); + } +} diff --git a/lib/data/store/schema.dart b/lib/data/store/schema.dart index 9ab85ff355..f4be9c530a 100644 --- a/lib/data/store/schema.dart +++ b/lib/data/store/schema.dart @@ -99,7 +99,11 @@ abstract final class SchemaVersion { /// name rather than by index, which shifted meaning every time a case /// was removed /// v25: saved RDP and VNC profiles become a syncable server child - static const current = 26; + /// v26: `server.ssh_enabled` and `monitor_enabled`, so one way into a server + /// can be switched off without its configuration being dropped + /// v27: `server.ssh_allow_legacy_algorithms`, so a host whose SSH daemon + /// only offers the retired `ssh-rsa`/SHA-1 algorithms can be reached + static const current = 27; /// Persisted locally, never included in a backup: it describes *this /// device's* storage, and restoring another device's number would make the diff --git a/lib/data/store/server.dart b/lib/data/store/server.dart index 96948b2b14..a5f1484044 100644 --- a/lib/data/store/server.dart +++ b/lib/data/store/server.dart @@ -168,6 +168,10 @@ class ServerStore extends EntityStore { (e) => e.name == row['ssh_file_transport'], ) ?? SshFileTransport.sftp, + // Null for every row written before the column, which is what + // those builds proposed: false. + allowLegacyAlgorithms: + (row['ssh_allow_legacy_algorithms'] as int? ?? 0) == 1, jumpId: jumps?.firstOrNull, jumpIds: jumps, ), @@ -306,6 +310,7 @@ class ServerStore extends EntityStore { 'ssh_alter_url', 'ssh_proxy_command', 'ssh_file_transport', + 'ssh_allow_legacy_algorithms', 'preferred_transport', 'ssh_enabled', 'monitor_enabled', @@ -345,6 +350,7 @@ class ServerStore extends EntityStore { ssh?.alterUrl, ssh?.proxyCommand, ssh?.fileTransport.name, + (ssh?.allowLegacyAlgorithms ?? false) ? 1 : 0, // Written only when it means something. A server with one way in has // nothing to prefer, and storing a value there would leave a preference // behind for the *other* transport if that one is ever configured. diff --git a/test/migration/m025_transport_switches_test.dart b/test/migration/m025_transport_switches_test.dart index b98b38ae0f..21aa3156ba 100644 --- a/test/migration/m025_transport_switches_test.dart +++ b/test/migration/m025_transport_switches_test.dart @@ -29,11 +29,17 @@ void main() { await SqliteDb.close(); }); - test('is the registered final step', () { + test('is registered at its own step', () { + // The chain's last step is asserted where that step lives; what matters + // here is that this one is in the list at the version it claims, since a + // step missing from `kSchemaMigrations` is a launch that throws on a real + // install and nothing in this file's other tests would notice. expect(const TransportSwitchesMigration().from, 25); - expect(SchemaVersion.current, 26); - expect(kSchemaMigrations.last, isA()); - expect(kSchemaMigrations.last.from, SchemaVersion.current - 1); + expect( + kSchemaMigrations.whereType(), + hasLength(1), + ); + expect(SchemaVersion.current, greaterThan(25)); }); test('adds the same columns a fresh database has', () async { diff --git a/test/migration/m026_ssh_legacy_algorithms_test.dart b/test/migration/m026_ssh_legacy_algorithms_test.dart new file mode 100644 index 0000000000..29db7adca8 --- /dev/null +++ b/test/migration/m026_ssh_legacy_algorithms_test.dart @@ -0,0 +1,107 @@ +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/data/model/server/server_private_info.dart'; +import 'package:server_box/data/model/server/ssh_credential.dart'; +import 'package:server_box/data/store/migrations/all.dart'; +import 'package:server_box/data/store/migrations/m026_ssh_legacy_algorithms.dart'; +import 'package:server_box/data/store/schema.dart'; +import 'package:server_box/data/store/server.dart'; +import 'package:server_box/data/store/tables.dart'; + +/// By name, not in order: `ADD COLUMN` appends, and a fresh table declares the +/// column in the middle. Nothing reads this table by position — the store names +/// every column it writes — so the order is not what has to match. +Map _serverColumns() => { + for (final row in SqliteDb.instance.select('PRAGMA table_info(server);')) + row['name'] as String: ( + row['type'] as String, + (row['notnull'] as int) == 1, + row['dflt_value'], + ), +}; + +void main() { + setUp(() => SqliteDb.openInMemory()); + + tearDown(() async { + await closeTables(); + await SqliteDb.close(); + }); + + test('is registered at its own step', () { + // The chain's last step is asserted where that step lives; what matters + // here is that this one is in the list at the version it claims, since a + // step missing from `kSchemaMigrations` is a launch that throws on a real + // install and nothing in this file's other tests would notice. + expect(const SshLegacyAlgorithmsMigration().from, 26); + expect( + kSchemaMigrations.whereType(), + hasLength(1), + ); + expect(SchemaVersion.current, greaterThan(26)); + expect(kSchemaMigrations.last.from, SchemaVersion.current - 1); + }); + + test('adds the same column a fresh database has', () async { + await createTables(SqliteDb.instance); + final fresh = _serverColumns(); + + await closeTables(); + await SqliteDb.close(); + SqliteDb.openInMemory(); + await createTables(SqliteDb.instance); + // What a v26 database looks like: the table without the switch. + SqliteDb.instance.execute( + 'ALTER TABLE server DROP COLUMN ssh_allow_legacy_algorithms;', + ); + await const SshLegacyAlgorithmsMigration().apply(); + + expect(_serverColumns(), fresh); + }); + + test('is safe to run again', () async { + await createTables(SqliteDb.instance); + final before = _serverColumns(); + await const SshLegacyAlgorithmsMigration().apply(); + expect(_serverColumns(), before); + }); + + /// The default is the whole safety of it: a server written before the column + /// was one of the many that never needed a retired algorithm, and has to go + /// on proposing exactly what it did. + test('a server written before the column is not opted in', () async { + await createTables(SqliteDb.instance); + SqliteDb.instance.execute( + 'ALTER TABLE server DROP COLUMN ssh_allow_legacy_algorithms;', + ); + SqliteDb.instance.execute( + 'INSERT INTO server (id, name, ssh_ip, ssh_port, ssh_user, monitor_addr) ' + "VALUES ('s1', 'old', '10.0.0.1', 22, 'root', 'https://agent:3770');", + ); + + await const SshLegacyAlgorithmsMigration().apply(); + + final spi = ServerStore().fetchOneRaw('s1'); + expect(spi, isNotNull); + expect(spi!.ssh?.allowLegacyAlgorithms, isFalse); + }); + + test('the switch survives the round trip', () async { + await createTables(SqliteDb.instance); + final store = ServerStore(); + const spi = Spi( + name: 'router', + id: 's2', + ssh: SshCredential( + ip: '10.0.0.2', + port: 22, + user: 'root', + allowLegacyAlgorithms: true, + ), + ); + store.put(spi); + + final read = store.fetchOneRaw('s2')!; + expect(read.ssh?.allowLegacyAlgorithms, isTrue); + }); +} diff --git a/test/unit/ssh/ssh_algorithms_test.dart b/test/unit/ssh/ssh_algorithms_test.dart new file mode 100644 index 0000000000..f991d1523d --- /dev/null +++ b/test/unit/ssh/ssh_algorithms_test.dart @@ -0,0 +1,91 @@ +/// The algorithm sets a server is offered, and the switch that picks one. +/// +/// The failure this exists for is a handshake that dies at host-key +/// negotiation with `No matching host key algorithm`, because the daemon on the +/// other end only knows the SHA-1 `ssh-rsa` spelling. What is asserted here is +/// that a server which has not asked for the retired algorithms is proposed +/// exactly dartssh2's default, and that opting in appends them rather than +/// replacing anything — the order being the whole reason a current host never +/// falls through to them. +library; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/core/utils/ssh_algorithms.dart'; +import 'package:server_box/data/model/server/ssh_credential.dart'; + +void main() { + const modern = SSHAlgorithms(); + const plain = SshCredential(ip: '10.0.0.1'); + const optedIn = SshCredential(ip: '10.0.0.1', allowLegacyAlgorithms: true); + + List names(Iterable algorithms) => + [for (final a in algorithms) (a as dynamic).name as String]; + + test('a server without the switch is proposed the default set', () { + final offered = SshAlgorithms.of(plain); + expect(names(offered.kex), names(modern.kex)); + expect(names(offered.hostkey), names(modern.hostkey)); + expect(names(offered.cipher), names(modern.cipher)); + expect(names(offered.mac), names(modern.mac)); + }); + + test('which no longer contains the retired spellings', () { + expect(names(modern.hostkey), isNot(contains('ssh-rsa'))); + expect( + names(modern.kex), + isNot(contains('diffie-hellman-group14-sha1')), + ); + expect(names(modern.cipher), isNot(contains('aes256-cbc'))); + expect(names(modern.mac), isNot(contains('hmac-sha1'))); + }); + + test('opting in appends them after the modern ones', () { + final offered = SshAlgorithms.of(optedIn); + + // Every modern algorithm is still there, and still first, so a host that + // offers one negotiates it and never reaches the tail. + expect( + names(offered.kex).take(modern.kex.length), + names(modern.kex), + ); + expect( + names(offered.hostkey).take(modern.hostkey.length), + names(modern.hostkey), + ); + expect( + names(offered.cipher).take(modern.cipher.length), + names(modern.cipher), + ); + expect(names(offered.mac).take(modern.mac.length), names(modern.mac)); + + // The host key that motivated this is last, so it is only reached when the + // server offers nothing newer. + expect(names(offered.hostkey).last, 'ssh-rsa'); + expect( + names(offered.kex), + containsAll(const [ + 'diffie-hellman-group14-sha1', + 'diffie-hellman-group-exchange-sha1', + 'diffie-hellman-group1-sha1', + ]), + ); + expect(names(offered.cipher), containsAll(const ['aes256-cbc', 'aes128-cbc'])); + expect( + names(offered.mac), + containsAll(const [ + 'hmac-sha1', + 'hmac-md5', + 'hmac-sha2-256-96', + 'hmac-sha2-512-96', + ]), + ); + }); + + test('the two switches are different sets, not the same list twice', () { + expect( + names(SshAlgorithms.of(plain).hostkey), + isNot(names(SshAlgorithms.of(optedIn).hostkey)), + ); + }); +} diff --git a/test/unit/ssh/ssh_credential_equality_test.dart b/test/unit/ssh/ssh_credential_equality_test.dart index e9c59980be..f5f6ab09f9 100644 --- a/test/unit/ssh/ssh_credential_equality_test.dart +++ b/test/unit/ssh/ssh_credential_equality_test.dart @@ -57,6 +57,21 @@ void main() { expect(scp.isSameAs(current), isTrue); }); + test('and so is the legacy-algorithms switch', () { + // A reconnect is required, not merely a different value: the set is chosen + // once, in a handshake that is already over. So it belongs in `isSameAs`, + // unlike the file transport above. + const permissive = SshCredential( + ip: '10.0.0.1', + user: 'me', + jumpIds: ['j-1'], + allowLegacyAlgorithms: true, + ); + expect(permissive, isNot(current)); + expect(permissive.hashCode, isNot(current.hashCode)); + expect(permissive.isSameAs(current), isFalse); + }); + test('no jump server at all hashes as no jump server', () { const a = SshCredential(ip: '10.0.0.1', user: 'me'); const b = SshCredential(ip: '10.0.0.1', user: 'me', jumpIds: []); From 04cdef631bf81915272b7b98e2fe0beb649893f9 Mon Sep 17 00:00:00 2001 From: GT610 Date: Sat, 19 Sep 2026 23:40:27 +0800 Subject: [PATCH 2/5] feat(server): expose the legacy-algorithms opt-in in the editor The switch lives in the server editor's SSH advanced group, beside the file transport and for the same reason: a fact about one old host that the app cannot work out for itself. It follows the SSH switch, since saving with SSH off writes ssh: null. Documented on the SSH connection page in both languages. --- docs/src/content/docs/principles/ssh.md | 11 ++++++++ docs/src/content/docs/zh/principles/ssh.md | 11 ++++++++ lib/generated/l10n/l10n.dart | 12 +++++++++ lib/generated/l10n/l10n_az.dart | 7 +++++ lib/generated/l10n/l10n_de.dart | 7 +++++ lib/generated/l10n/l10n_en.dart | 7 +++++ lib/generated/l10n/l10n_es.dart | 7 +++++ lib/generated/l10n/l10n_fr.dart | 7 +++++ lib/generated/l10n/l10n_id.dart | 7 +++++ lib/generated/l10n/l10n_it.dart | 7 +++++ lib/generated/l10n/l10n_ja.dart | 7 +++++ lib/generated/l10n/l10n_ko.dart | 7 +++++ lib/generated/l10n/l10n_nl.dart | 7 +++++ lib/generated/l10n/l10n_pt.dart | 7 +++++ lib/generated/l10n/l10n_ru.dart | 7 +++++ lib/generated/l10n/l10n_tr.dart | 7 +++++ lib/generated/l10n/l10n_uk.dart | 7 +++++ lib/generated/l10n/l10n_zh.dart | 7 +++++ lib/l10n/app_en.arb | 2 ++ lib/l10n/app_zh.arb | 2 ++ lib/view/page/server/edit/actions.dart | 2 ++ lib/view/page/server/edit/edit.dart | 7 +++++ lib/view/page/server/edit/widget.dart | 31 ++++++++++++++++++++++ 23 files changed, 183 insertions(+) diff --git a/docs/src/content/docs/principles/ssh.md b/docs/src/content/docs/principles/ssh.md index 9ee7c2d5c2..4dbd818319 100644 --- a/docs/src/content/docs/principles/ssh.md +++ b/docs/src/content/docs/principles/ssh.md @@ -37,6 +37,17 @@ final class SshCredential { Jump-server candidates and `ProxyCommand` are mutually exclusive. `Spix.validate()` rejects a server that configures both. +### Legacy algorithms + +dartssh2 proposes a modern-only set. RSA host keys are still offered, but only under the RFC 8332 names (`rsa-sha2-256`, `rsa-sha2-512`); the SHA-1 `ssh-rsa` spelling it replaced, the SHA-1 key exchanges, the CBC ciphers and the SHA-1/MD5 MACs are not in the list at all. An old daemon that predates those names — a router's dropbear, a switch — advertises only `ssh-rsa`, and the handshake ends before authentication: + +```text +SSHAuthAbortError(... reason: SSHInternalError( + Bad state: No matching host key algorithm)) +``` + +`SshCredential.allowLegacyAlgorithms` is the per-server answer, turned on in the server editor under **SSH advanced**. The retired algorithms are appended *after* the modern ones, so a host that offers anything current still negotiates it and only one with nothing else falls through. It is off for every server unless you turn it on: KEXINIT is unauthenticated, so a list containing SHA-1 can be forced on a connection by an attacker even when the server would have offered something better. + ### Creating the client `genClient(spi)` creates and returns an SSH client: diff --git a/docs/src/content/docs/zh/principles/ssh.md b/docs/src/content/docs/zh/principles/ssh.md index a23045c096..0989a3a25b 100644 --- a/docs/src/content/docs/zh/principles/ssh.md +++ b/docs/src/content/docs/zh/principles/ssh.md @@ -37,6 +37,17 @@ final class SshCredential { Jump server 链与 `ProxyCommand` 互斥。两者同时配置时,`Spix.validate()` 会拒绝该服务器配置。 +### 兼容旧版算法 + +dartssh2 默认只提议现代算法。RSA 主机密钥仍然提供,但只有 RFC 8332 的两个名字(`rsa-sha2-256`、`rsa-sha2-512`);它取代的 SHA-1 `ssh-rsa` 写法、SHA-1 密钥交换、CBC 加密和 SHA-1/MD5 MAC 都不在列表里。早于这些名字的旧服务端——路由器的 dropbear、交换机——只会广播 `ssh-rsa`,握手会在认证之前就结束: + +```text +SSHAuthAbortError(... reason: SSHInternalError( + Bad state: No matching host key algorithm)) +``` + +`SshCredential.allowLegacyAlgorithms` 是按服务器给出的答案,在服务器编辑页的 **SSH 高级** 里开启。被淘汰的算法追加在现代算法**之后**,所以还能提供现代算法的设备依旧协商到它,只有一无所有的设备才会落到这一段。它对每台服务器默认关闭:KEXINIT 未受认证,一份包含 SHA-1 的列表可能被攻击者强加到一条本可以协商更好的连接上。 + ### 创建 client `genClient(spi)` 会创建并返回 SSH client: diff --git a/lib/generated/l10n/l10n.dart b/lib/generated/l10n/l10n.dart index bcac25249a..fc3755eb6d 100644 --- a/lib/generated/l10n/l10n.dart +++ b/lib/generated/l10n/l10n.dart @@ -4540,6 +4540,18 @@ abstract class AppLocalizations { /// **'Fallback destination, ProxyCommand, jump server, file transport, remote path'** String get sshAdvancedTip; + /// No description provided for @sshLegacyAlgorithms. + /// + /// In en, this message translates to: + /// **'Legacy algorithms'** + String get sshLegacyAlgorithms; + + /// No description provided for @sshLegacyAlgorithmsTip. + /// + /// In en, this message translates to: + /// **'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'** + String get sshLegacyAlgorithmsTip; + /// No description provided for @appearanceAndPlace. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/l10n_az.dart b/lib/generated/l10n/l10n_az.dart index 380c772105..75270050fe 100644 --- a/lib/generated/l10n/l10n_az.dart +++ b/lib/generated/l10n/l10n_az.dart @@ -2646,6 +2646,13 @@ class AppLocalizationsAz extends AppLocalizations { String get sshAdvancedTip => 'Ehtiyat ünvan, ProxyCommand, keçid serveri, fayl nəqli, uzaq yol'; + @override + String get sshLegacyAlgorithms => 'Legacy algorithms'; + + @override + String get sshLegacyAlgorithmsTip => + 'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'; + @override String get appearanceAndPlace => 'Görünüş və yer'; diff --git a/lib/generated/l10n/l10n_de.dart b/lib/generated/l10n/l10n_de.dart index 878c27eb5f..8e6d841dc9 100644 --- a/lib/generated/l10n/l10n_de.dart +++ b/lib/generated/l10n/l10n_de.dart @@ -2662,6 +2662,13 @@ class AppLocalizationsDe extends AppLocalizations { String get sshAdvancedTip => 'Ausweichziel, ProxyCommand, Sprungserver, Dateiübertragung, entfernter Pfad'; + @override + String get sshLegacyAlgorithms => 'Legacy algorithms'; + + @override + String get sshLegacyAlgorithmsTip => + 'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'; + @override String get appearanceAndPlace => 'Darstellung & Ort'; diff --git a/lib/generated/l10n/l10n_en.dart b/lib/generated/l10n/l10n_en.dart index 9697aa8b8d..dc86312991 100644 --- a/lib/generated/l10n/l10n_en.dart +++ b/lib/generated/l10n/l10n_en.dart @@ -2633,6 +2633,13 @@ class AppLocalizationsEn extends AppLocalizations { String get sshAdvancedTip => 'Fallback destination, ProxyCommand, jump server, file transport, remote path'; + @override + String get sshLegacyAlgorithms => 'Legacy algorithms'; + + @override + String get sshLegacyAlgorithmsTip => + 'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'; + @override String get appearanceAndPlace => 'Appearance & location'; diff --git a/lib/generated/l10n/l10n_es.dart b/lib/generated/l10n/l10n_es.dart index 8f48e8dc2d..b9f8208f5d 100644 --- a/lib/generated/l10n/l10n_es.dart +++ b/lib/generated/l10n/l10n_es.dart @@ -2671,6 +2671,13 @@ class AppLocalizationsEs extends AppLocalizations { String get sshAdvancedTip => 'Destino alternativo, ProxyCommand, servidor de salto, transporte de archivos, ruta remota'; + @override + String get sshLegacyAlgorithms => 'Legacy algorithms'; + + @override + String get sshLegacyAlgorithmsTip => + 'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'; + @override String get appearanceAndPlace => 'Apariencia y ubicación'; diff --git a/lib/generated/l10n/l10n_fr.dart b/lib/generated/l10n/l10n_fr.dart index 818eaecfe2..efa3d55f56 100644 --- a/lib/generated/l10n/l10n_fr.dart +++ b/lib/generated/l10n/l10n_fr.dart @@ -2671,6 +2671,13 @@ class AppLocalizationsFr extends AppLocalizations { String get sshAdvancedTip => 'Destination de repli, ProxyCommand, serveur de rebond, transfert de fichiers, chemin distant'; + @override + String get sshLegacyAlgorithms => 'Legacy algorithms'; + + @override + String get sshLegacyAlgorithmsTip => + 'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'; + @override String get appearanceAndPlace => 'Apparence et lieu'; diff --git a/lib/generated/l10n/l10n_id.dart b/lib/generated/l10n/l10n_id.dart index 67a4ee57f8..883b34bd5f 100644 --- a/lib/generated/l10n/l10n_id.dart +++ b/lib/generated/l10n/l10n_id.dart @@ -2634,6 +2634,13 @@ class AppLocalizationsId extends AppLocalizations { String get sshAdvancedTip => 'Tujuan cadangan, ProxyCommand, server lompat, transport berkas, jalur jarak jauh'; + @override + String get sshLegacyAlgorithms => 'Legacy algorithms'; + + @override + String get sshLegacyAlgorithmsTip => + 'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'; + @override String get appearanceAndPlace => 'Tampilan & lokasi'; diff --git a/lib/generated/l10n/l10n_it.dart b/lib/generated/l10n/l10n_it.dart index 4679b536eb..d97605287c 100644 --- a/lib/generated/l10n/l10n_it.dart +++ b/lib/generated/l10n/l10n_it.dart @@ -2663,6 +2663,13 @@ class AppLocalizationsIt extends AppLocalizations { String get sshAdvancedTip => 'Destinazione di riserva, ProxyCommand, server di salto, trasporto file, percorso remoto'; + @override + String get sshLegacyAlgorithms => 'Legacy algorithms'; + + @override + String get sshLegacyAlgorithmsTip => + 'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'; + @override String get appearanceAndPlace => 'Aspetto e luogo'; diff --git a/lib/generated/l10n/l10n_ja.dart b/lib/generated/l10n/l10n_ja.dart index b3f59667f4..bbb504a149 100644 --- a/lib/generated/l10n/l10n_ja.dart +++ b/lib/generated/l10n/l10n_ja.dart @@ -2512,6 +2512,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get sshAdvancedTip => '代替の接続先、ProxyCommand、踏み台、ファイル転送、リモートのパス'; + @override + String get sshLegacyAlgorithms => 'Legacy algorithms'; + + @override + String get sshLegacyAlgorithmsTip => + 'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'; + @override String get appearanceAndPlace => '外観と場所'; diff --git a/lib/generated/l10n/l10n_ko.dart b/lib/generated/l10n/l10n_ko.dart index a246a08d05..46c37344fd 100644 --- a/lib/generated/l10n/l10n_ko.dart +++ b/lib/generated/l10n/l10n_ko.dart @@ -2520,6 +2520,13 @@ class AppLocalizationsKo extends AppLocalizations { @override String get sshAdvancedTip => '대체 대상, ProxyCommand, 점프 서버, 파일 전송, 원격 경로'; + @override + String get sshLegacyAlgorithms => 'Legacy algorithms'; + + @override + String get sshLegacyAlgorithmsTip => + 'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'; + @override String get appearanceAndPlace => '모양과 위치'; diff --git a/lib/generated/l10n/l10n_nl.dart b/lib/generated/l10n/l10n_nl.dart index 7f747a5f08..4614c34e8f 100644 --- a/lib/generated/l10n/l10n_nl.dart +++ b/lib/generated/l10n/l10n_nl.dart @@ -2654,6 +2654,13 @@ class AppLocalizationsNl extends AppLocalizations { String get sshAdvancedTip => 'Uitwijkbestemming, ProxyCommand, springserver, bestandstransport, extern pad'; + @override + String get sshLegacyAlgorithms => 'Legacy algorithms'; + + @override + String get sshLegacyAlgorithmsTip => + 'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'; + @override String get appearanceAndPlace => 'Weergave en locatie'; diff --git a/lib/generated/l10n/l10n_pt.dart b/lib/generated/l10n/l10n_pt.dart index 4af4466c67..8b87a1cee5 100644 --- a/lib/generated/l10n/l10n_pt.dart +++ b/lib/generated/l10n/l10n_pt.dart @@ -2650,6 +2650,13 @@ class AppLocalizationsPt extends AppLocalizations { String get sshAdvancedTip => 'Destino alternativo, ProxyCommand, servidor de salto, transporte de arquivos, caminho remoto'; + @override + String get sshLegacyAlgorithms => 'Legacy algorithms'; + + @override + String get sshLegacyAlgorithmsTip => + 'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'; + @override String get appearanceAndPlace => 'Aparência e local'; diff --git a/lib/generated/l10n/l10n_ru.dart b/lib/generated/l10n/l10n_ru.dart index 8596763405..d424f19e5f 100644 --- a/lib/generated/l10n/l10n_ru.dart +++ b/lib/generated/l10n/l10n_ru.dart @@ -2655,6 +2655,13 @@ class AppLocalizationsRu extends AppLocalizations { String get sshAdvancedTip => 'Запасной адрес, ProxyCommand, промежуточный сервер, передача файлов, путь на сервере'; + @override + String get sshLegacyAlgorithms => 'Legacy algorithms'; + + @override + String get sshLegacyAlgorithmsTip => + 'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'; + @override String get appearanceAndPlace => 'Вид и место'; diff --git a/lib/generated/l10n/l10n_tr.dart b/lib/generated/l10n/l10n_tr.dart index 2a2c8a3ab9..17593ca12c 100644 --- a/lib/generated/l10n/l10n_tr.dart +++ b/lib/generated/l10n/l10n_tr.dart @@ -2629,6 +2629,13 @@ class AppLocalizationsTr extends AppLocalizations { String get sshAdvancedTip => 'Yedek hedef, ProxyCommand, atlama sunucusu, dosya aktarımı, uzak yol'; + @override + String get sshLegacyAlgorithms => 'Legacy algorithms'; + + @override + String get sshLegacyAlgorithmsTip => + 'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'; + @override String get appearanceAndPlace => 'Görünüm ve konum'; diff --git a/lib/generated/l10n/l10n_uk.dart b/lib/generated/l10n/l10n_uk.dart index 22ddbaede9..da1920ce8d 100644 --- a/lib/generated/l10n/l10n_uk.dart +++ b/lib/generated/l10n/l10n_uk.dart @@ -2650,6 +2650,13 @@ class AppLocalizationsUk extends AppLocalizations { String get sshAdvancedTip => 'Запасна адреса, ProxyCommand, проміжний сервер, передавання файлів, шлях на сервері'; + @override + String get sshLegacyAlgorithms => 'Legacy algorithms'; + + @override + String get sshLegacyAlgorithmsTip => + 'For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.'; + @override String get appearanceAndPlace => 'Вигляд і місце'; diff --git a/lib/generated/l10n/l10n_zh.dart b/lib/generated/l10n/l10n_zh.dart index 76cc475c13..2f31459deb 100644 --- a/lib/generated/l10n/l10n_zh.dart +++ b/lib/generated/l10n/l10n_zh.dart @@ -2436,6 +2436,13 @@ class AppLocalizationsZh extends AppLocalizations { @override String get sshAdvancedTip => '备用地址、ProxyCommand、跳板机、文件传输、远端路径'; + @override + String get sshLegacyAlgorithms => '兼容旧版算法'; + + @override + String get sshLegacyAlgorithmsTip => + '用于只提供 SHA-1 `ssh-rsa` 主机密钥或 SHA-1 密钥交换的旧 SSH 服务端(路由器、交换机)。安全性较低,仅在设备确实需要时开启。'; + @override String get appearanceAndPlace => '外观与位置'; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 163e5caf20..7d6488b5eb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1027,6 +1027,8 @@ "optionalTip": "Nothing here is needed to connect. Open one and its fields take over the form.", "sshAdvanced": "SSH advanced", "sshAdvancedTip": "Fallback destination, ProxyCommand, jump server, file transport, remote path", + "sshLegacyAlgorithms": "Legacy algorithms", + "sshLegacyAlgorithmsTip": "For an old SSH daemon (a router, a switch) that only offers the SHA-1 `ssh-rsa` host key or a SHA-1 key exchange. Less secure; turn it on only for a host that needs it.", "appearanceAndPlace": "Appearance & location", "appearanceAndPlaceTip": "Logo, coordinates", "statusCollection": "Status collection", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a0724a66bb..f8a80a4afd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -765,6 +765,8 @@ "optionalTip": "这里的东西都不是连接所必需的。展开一项,它的字段会接管表单。", "sshAdvanced": "SSH 高级", "sshAdvancedTip": "备用地址、ProxyCommand、跳板机、文件传输、远端路径", + "sshLegacyAlgorithms": "兼容旧版算法", + "sshLegacyAlgorithmsTip": "用于只提供 SHA-1 `ssh-rsa` 主机密钥或 SHA-1 密钥交换的旧 SSH 服务端(路由器、交换机)。安全性较低,仅在设备确实需要时开启。", "appearanceAndPlace": "外观与位置", "appearanceAndPlaceTip": "Logo、坐标", "statusCollection": "状态采集", diff --git a/lib/view/page/server/edit/actions.dart b/lib/view/page/server/edit/actions.dart index d1c47b8460..ebbd870b38 100644 --- a/lib/view/page/server/edit/actions.dart +++ b/lib/view/page/server/edit/actions.dart @@ -581,6 +581,7 @@ extension _Actions on _ServerEditPageState { jumpIds: _jumpServers.value.isEmpty ? null : _jumpServers.value, proxyCommand: proxyCommandText.selfNotEmptyOrNull, fileTransport: _fileTransport.value, + allowLegacyAlgorithms: _allowLegacyAlgorithms.value, ); final wolEmpty = @@ -858,6 +859,7 @@ extension _Utils on _ServerEditPageState { _jumpServers.value = ssh.resolvedJumpIds; _proxyCommandCtrl.text = ssh.proxyCommand ?? ''; _fileTransport.value = ssh.fileTransport; + _allowLegacyAlgorithms.value = ssh.allowLegacyAlgorithms; } /// List in dart is passed by pointer, so you need to copy it here diff --git a/lib/view/page/server/edit/edit.dart b/lib/view/page/server/edit/edit.dart index 0d069f8d4f..591bbf9988 100644 --- a/lib/view/page/server/edit/edit.dart +++ b/lib/view/page/server/edit/edit.dart @@ -148,6 +148,12 @@ class _ServerEditPageState extends ConsumerState /// answer by itself. final _fileTransport = ValueNotifier(SshFileTransport.sftp); + /// Whether this host is allowed the algorithms SSH has retired — see + /// [SshCredential.allowLegacyAlgorithms]. Beside the file transport because + /// it is the same kind of answer: a fact about one old host that the app + /// cannot work out for itself. + final _allowLegacyAlgorithms = ValueNotifier(false); + final _tempIsCelsius = ValueNotifier(false); final _env = {}.vn; @@ -230,6 +236,7 @@ class _ServerEditPageState extends ConsumerState _useMonitorHttp.dispose(); _preferMonitorHttp.dispose(); _fileTransport.dispose(); + _allowLegacyAlgorithms.dispose(); _tempIsCelsius.dispose(); _env.dispose(); _unmigratedCmds.dispose(); diff --git a/lib/view/page/server/edit/widget.dart b/lib/view/page/server/edit/widget.dart index 9b10165088..98668d2ec3 100644 --- a/lib/view/page/server/edit/widget.dart +++ b/lib/view/page/server/edit/widget.dart @@ -531,6 +531,7 @@ extension _Widgets on _ServerEditPageState { _buildProxyCommand(), _buildJumpServer(), _buildFileTransport(), + _buildAllowLegacyAlgorithms(), _buildScriptDir(), _buildSystemType(), ], @@ -824,6 +825,36 @@ extension _Widgets on _ServerEditPageState { }); } + /// Whether this host is allowed the algorithms SSH has retired. + /// + /// Follows the *SSH* switch, for the file transport's reason: with SSH off + /// the save writes `ssh: null`, so a choice made here would be accepted, + /// saved and discarded without a word. + /// + /// A switch rather than something the app finds out for itself. The handshake + /// is where the answer would be, and the only way to get there is to try a + /// set the server has already refused — which for a host that *is* current + /// means giving a stranger a list containing SHA-1 that they do not need. + Widget _buildAllowLegacyAlgorithms() { + return _useSsh.listenVal((useSsh) { + if (!useSsh) return UIs.placeholder; + return _allowLegacyAlgorithms.listenVal((val) { + return ListTile( + leading: const Icon(MingCute.lock_line), + title: TipText( + l10n.sshLegacyAlgorithms, + l10n.sshLegacyAlgorithmsTip, + ), + trailing: SwitchX( + value: val, + onChanged: (v) => _allowLegacyAlgorithms.value = v, + ), + onTap: () => _allowLegacyAlgorithms.value = !val, + ).cardx; + }); + }); + } + Widget _buildPVEs() { const addr = 'https://127.0.0.1:8006'; return _keyIdx.listenVal((v) { From da9cd1d26bd60a6e58a3fff2001b3a991f5fd13d Mon Sep 17 00:00:00 2001 From: GT610 Date: Sat, 19 Sep 2026 23:50:41 +0800 Subject: [PATCH 3/5] docs(ssh): list allowLegacyAlgorithms in the credential model --- docs/src/content/docs/principles/ssh.md | 1 + docs/src/content/docs/zh/principles/ssh.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/src/content/docs/principles/ssh.md b/docs/src/content/docs/principles/ssh.md index 4dbd818319..8bd78bcf00 100644 --- a/docs/src/content/docs/principles/ssh.md +++ b/docs/src/content/docs/principles/ssh.md @@ -32,6 +32,7 @@ final class SshCredential { String? alterUrl; // Fallback URL List? jumpIds; // Jump-server candidates String? proxyCommand; // ProxyCommand, desktop only + bool allowLegacyAlgorithms; // Opt in to algorithms SSH has retired, false by default } ``` diff --git a/docs/src/content/docs/zh/principles/ssh.md b/docs/src/content/docs/zh/principles/ssh.md index 0989a3a25b..fe48910252 100644 --- a/docs/src/content/docs/zh/principles/ssh.md +++ b/docs/src/content/docs/zh/principles/ssh.md @@ -32,6 +32,7 @@ final class SshCredential { String? alterUrl; // 备用 URL List? jumpIds; // Jump server 链 String? proxyCommand; // ProxyCommand,仅桌面端 + bool allowLegacyAlgorithms; // 允许协商已被 SSH 淘汰的算法,默认关闭 } ``` From 5f7e10ff85377855553e2471acaf9e6195d0989d Mon Sep 17 00:00:00 2001 From: GT610 Date: Sat, 19 Sep 2026 23:59:15 +0800 Subject: [PATCH 4/5] docs(ssh): say the legacy fallback is per algorithm category Host key, key exchange, cipher and MAC are negotiated independently, so the retired algorithms only apply to a category the server offers nothing current for. Also state that the switch is configured per server. --- docs/src/content/docs/principles/ssh.md | 2 +- docs/src/content/docs/zh/principles/ssh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/principles/ssh.md b/docs/src/content/docs/principles/ssh.md index 8bd78bcf00..5cd1505698 100644 --- a/docs/src/content/docs/principles/ssh.md +++ b/docs/src/content/docs/principles/ssh.md @@ -47,7 +47,7 @@ SSHAuthAbortError(... reason: SSHInternalError( Bad state: No matching host key algorithm)) ``` -`SshCredential.allowLegacyAlgorithms` is the per-server answer, turned on in the server editor under **SSH advanced**. The retired algorithms are appended *after* the modern ones, so a host that offers anything current still negotiates it and only one with nothing else falls through. It is off for every server unless you turn it on: KEXINIT is unauthenticated, so a list containing SHA-1 can be forced on a connection by an attacker even when the server would have offered something better. +`SshCredential.allowLegacyAlgorithms` is configured per server, turned on in the server editor under **SSH advanced**. The four algorithm categories — host key, key exchange, cipher and MAC — are negotiated independently, and the retired algorithms are appended *after* the modern ones in each. The fallback therefore applies only within the category that has no modern option: a host with a current host key but only a SHA-1 key exchange keeps the modern host key and falls back for the kex alone. It is off for every server unless you turn it on: KEXINIT is unauthenticated, so a list containing SHA-1 can be forced on a connection by an attacker even when the server would have offered something better. ### Creating the client diff --git a/docs/src/content/docs/zh/principles/ssh.md b/docs/src/content/docs/zh/principles/ssh.md index fe48910252..72da12cfdd 100644 --- a/docs/src/content/docs/zh/principles/ssh.md +++ b/docs/src/content/docs/zh/principles/ssh.md @@ -47,7 +47,7 @@ SSHAuthAbortError(... reason: SSHInternalError( Bad state: No matching host key algorithm)) ``` -`SshCredential.allowLegacyAlgorithms` 是按服务器给出的答案,在服务器编辑页的 **SSH 高级** 里开启。被淘汰的算法追加在现代算法**之后**,所以还能提供现代算法的设备依旧协商到它,只有一无所有的设备才会落到这一段。它对每台服务器默认关闭:KEXINIT 未受认证,一份包含 SHA-1 的列表可能被攻击者强加到一条本可以协商更好的连接上。 +`SshCredential.allowLegacyAlgorithms` 是按服务器单独配置的开关,在服务器编辑页的 **SSH 高级** 里开启。主机密钥、密钥交换、加密和 MAC 这四类算法各自独立协商,被淘汰的算法在每一类里都追加在现代算法**之后**。因此降级只发生在没有现代算法可选的那一类:一台主机密钥很新、却只有 SHA-1 密钥交换的设备,仍会保留现代主机密钥,只在密钥交换上回退。它对每台服务器默认关闭:KEXINIT 未受认证,一份包含 SHA-1 的列表可能被攻击者强加到一条本可以协商更好的连接上。 ### 创建 client From 634ed9d5eebcc99caf130348fe60022a3fb08d8e Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 20 Sep 2026 00:08:44 +0800 Subject: [PATCH 5/5] docs(ssh): correct the legacy-algorithms security note KEXINIT is bound into the exchange hash the host key signs (computeExchangeHash covers both payloads, and _verifyHostkey checks the signature), so a peer cannot silently force a weaker list on a connection whose key is verified. State the real reason the algorithms are retired - SHA-1 and small DH groups are weak - and recommend enabling the switch only for a trusted host. --- docs/src/content/docs/principles/ssh.md | 2 +- docs/src/content/docs/zh/principles/ssh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/principles/ssh.md b/docs/src/content/docs/principles/ssh.md index 5cd1505698..34a1e18fa3 100644 --- a/docs/src/content/docs/principles/ssh.md +++ b/docs/src/content/docs/principles/ssh.md @@ -47,7 +47,7 @@ SSHAuthAbortError(... reason: SSHInternalError( Bad state: No matching host key algorithm)) ``` -`SshCredential.allowLegacyAlgorithms` is configured per server, turned on in the server editor under **SSH advanced**. The four algorithm categories — host key, key exchange, cipher and MAC — are negotiated independently, and the retired algorithms are appended *after* the modern ones in each. The fallback therefore applies only within the category that has no modern option: a host with a current host key but only a SHA-1 key exchange keeps the modern host key and falls back for the kex alone. It is off for every server unless you turn it on: KEXINIT is unauthenticated, so a list containing SHA-1 can be forced on a connection by an attacker even when the server would have offered something better. +`SshCredential.allowLegacyAlgorithms` is configured per server, turned on in the server editor under **SSH advanced**. The four algorithm categories — host key, key exchange, cipher and MAC — are negotiated independently, and the retired algorithms are appended *after* the modern ones in each. The fallback therefore applies only within the category that has no modern option: a host with a current host key but only a SHA-1 key exchange keeps the modern host key and falls back for the kex alone. These algorithms are retired because they are weak — SHA-1 signatures and key exchanges, and small Diffie-Hellman groups — so opting in allows a weaker connection than the default; it does not let a peer force one onto an otherwise-modern connection, since the KEXINIT name-lists are covered by the exchange hash the host key signs. Turn it on only for a host you trust and that cannot be reached without it. ### Creating the client diff --git a/docs/src/content/docs/zh/principles/ssh.md b/docs/src/content/docs/zh/principles/ssh.md index 72da12cfdd..dd2ab1ecd3 100644 --- a/docs/src/content/docs/zh/principles/ssh.md +++ b/docs/src/content/docs/zh/principles/ssh.md @@ -47,7 +47,7 @@ SSHAuthAbortError(... reason: SSHInternalError( Bad state: No matching host key algorithm)) ``` -`SshCredential.allowLegacyAlgorithms` 是按服务器单独配置的开关,在服务器编辑页的 **SSH 高级** 里开启。主机密钥、密钥交换、加密和 MAC 这四类算法各自独立协商,被淘汰的算法在每一类里都追加在现代算法**之后**。因此降级只发生在没有现代算法可选的那一类:一台主机密钥很新、却只有 SHA-1 密钥交换的设备,仍会保留现代主机密钥,只在密钥交换上回退。它对每台服务器默认关闭:KEXINIT 未受认证,一份包含 SHA-1 的列表可能被攻击者强加到一条本可以协商更好的连接上。 +`SshCredential.allowLegacyAlgorithms` 是按服务器单独配置的开关,在服务器编辑页的 **SSH 高级** 里开启。主机密钥、密钥交换、加密和 MAC 这四类算法各自独立协商,被淘汰的算法在每一类里都追加在现代算法**之后**。因此降级只发生在没有现代算法可选的那一类:一台主机密钥很新、却只有 SHA-1 密钥交换的设备,仍会保留现代主机密钥,只在密钥交换上回退。这些算法被淘汰是因为它们本身很弱——SHA-1 签名与密钥交换、以及小位数的 Diffie-Hellman 群——所以开启这个开关意味着允许一条比默认更弱的连接;但它不会让对端在本可走现代算法的连接上强制降级,因为 KEXINIT 的算法列表由主机密钥签名的交换哈希覆盖。请只对确实无法用其他方式连上、并且你信任的设备开启。 ### 创建 client