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
12 changes: 12 additions & 0 deletions docs/src/content/docs/principles/ssh.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,23 @@ final class SshCredential {
String? alterUrl; // Fallback URL
List<String>? jumpIds; // Jump-server candidates
String? proxyCommand; // ProxyCommand, desktop only
bool allowLegacyAlgorithms; // Opt in to algorithms SSH has retired, false by default
}
```

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 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

`genClient(spi)` creates and returns an SSH client:
Expand Down
12 changes: 12 additions & 0 deletions docs/src/content/docs/zh/principles/ssh.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,23 @@ final class SshCredential {
String? alterUrl; // 备用 URL
List<String>? jumpIds; // Jump server 链
String? proxyCommand; // ProxyCommand,仅桌面端
bool allowLegacyAlgorithms; // 允许协商已被 SSH 淘汰的算法,默认关闭
}
```

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 高级** 里开启。主机密钥、密钥交换、加密和 MAC 这四类算法各自独立协商,被淘汰的算法在每一类里都追加在现代算法**之后**。因此降级只发生在没有现代算法可选的那一类:一台主机密钥很新、却只有 SHA-1 密钥交换的设备,仍会保留现代主机密钥,只在密钥交换上回退。这些算法被淘汰是因为它们本身很弱——SHA-1 签名与密钥交换、以及小位数的 Diffie-Hellman 群——所以开启这个开关意味着允许一条比默认更弱的连接;但它不会让对端在本可走现代算法的连接上强制降级,因为 KEXINIT 的算法列表由主机密钥签名的交换哈希覆盖。请只对确实无法用其他方式连上、并且你信任的设备开启。

### 创建 client

`genClient(spi)` 会创建并返回 SSH client:
Expand Down
3 changes: 3 additions & 0 deletions lib/core/utils/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -662,6 +663,7 @@ Future<SSHClient> _authenticatedClient({
? null
: (request) => onKeyboardInteractive(spi, request),
onVerifyHostKey: hostKeyVerifier.call,
algorithms: SshAlgorithms.of(ssh),
handshakeTimeout: timeout,
authTimeout: timeout,
);
Expand Down Expand Up @@ -752,6 +754,7 @@ Future<SSHClient> _authenticatedClient({
? null
: (request) => onKeyboardInteractive(spi, request),
onVerifyHostKey: hostKeyVerifier.call,
algorithms: SshAlgorithms.of(ssh),
handshakeTimeout: timeout,
authTimeout: timeout,
);
Expand Down
68 changes: 68 additions & 0 deletions lib/core/utils/ssh_algorithms.dart
Original file line number Diff line number Diff line change
@@ -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,
],
);
}
22 changes: 21 additions & 1 deletion lib/data/model/server/ssh_credential.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -114,6 +126,7 @@ final class SshCredential {
this.jumpIds,
this.proxyCommand,
this.fileTransport = SshFileTransport.sftp,
this.allowLegacyAlgorithms = false,
});

factory SshCredential.fromJson(Map<String, dynamic> json) =>
Expand Down Expand Up @@ -235,6 +248,7 @@ final class SshCredential {
Object? jumpIds = _unset,
Object? proxyCommand = _unset,
SshFileTransport? fileTransport,
bool? allowLegacyAlgorithms,
}) {
return SshCredential(
ip: ip ?? this.ip,
Expand All @@ -253,6 +267,8 @@ final class SshCredential {
? this.proxyCommand
: proxyCommand as String?,
fileTransport: fileTransport ?? this.fileTransport,
allowLegacyAlgorithms:
allowLegacyAlgorithms ?? this.allowLegacyAlgorithms,
);
}

Expand All @@ -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
Expand Down Expand Up @@ -302,6 +321,7 @@ final class SshCredential {
Object.hashAll(resolvedJumpIds),
proxyCommand,
fileTransport,
allowLegacyAlgorithms,
);
}

Expand Down
2 changes: 2 additions & 0 deletions lib/data/model/server/ssh_credential.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/data/provider/server/all.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/data/provider/server/single.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions lib/data/store/db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading