Skip to content

Commit 8f58b49

Browse files
committed
outbound/ssh: add cipher, MAC, and key exchange configuration
Ability to specify client's cipher preference is useful. In particular, often `aes128-gcm` is more efficient but `chacha-poly1305` is selected instead.
1 parent 3367bde commit 8f58b49

5 files changed

Lines changed: 248 additions & 0 deletions

File tree

docs/configuration/outbound/ssh.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@
1717
],
1818
"host_key_algorithms": [],
1919
"client_version": "SSH-2.0-OpenSSH_7.4p1",
20+
"ciphers": [
21+
"aes128-gcm@openssh.com",
22+
"chacha20-poly1305@openssh.com"
23+
],
24+
"macs": [
25+
"hmac-sha2-256-etm@openssh.com"
26+
],
27+
"key_exchanges": [
28+
"curve25519-sha256"
29+
],
2030

2131
... // Dial Fields
2232
}
@@ -66,6 +76,24 @@ Host key algorithms.
6676

6777
Client version. Random version will be used if empty.
6878

79+
#### ciphers
80+
81+
List of cipher algorithms to use. If empty, SSH defaults will be used.
82+
83+
Available values: `aes128-gcm@openssh.com` | `aes256-gcm@openssh.com` | `chacha20-poly1305@openssh.com` | `aes128-ctr` | `aes192-ctr` | `aes256-ctr`.
84+
85+
#### macs
86+
87+
List of MAC (Message Authentication Code) algorithms to use. If empty, SSH defaults will be used.
88+
89+
Available values: `hmac-sha2-256-etm@openssh.com` | `hmac-sha2-512-etm@openssh.com` | `hmac-sha2-256` | `hmac-sha2-512` | `hmac-sha1`.
90+
91+
#### key_exchanges
92+
93+
List of key exchange algorithms to use. If empty, SSH defaults will be used.
94+
95+
Available values: `mlkem768x25519-sha256` | `curve25519-sha256` | `ecdh-sha2-nistp256` | `ecdh-sha2-nistp384` | `ecdh-sha2-nistp521` | `diffie-hellman-group14-sha256` | `diffie-hellman-group16-sha512` | `diffie-hellman-group-exchange-sha256`.
96+
6997
### Dial Fields
7098

7199
See [Dial Fields](/configuration/shared/dial/) for details.

docs/configuration/outbound/ssh.zh.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@
1717
],
1818
"host_key_algorithms": [],
1919
"client_version": "SSH-2.0-OpenSSH_7.4p1",
20+
"ciphers": [
21+
"aes128-gcm@openssh.com",
22+
"chacha20-poly1305@openssh.com"
23+
],
24+
"macs": [
25+
"hmac-sha2-256-etm@openssh.com"
26+
],
27+
"key_exchanges": [
28+
"curve25519-sha256"
29+
],
2030

2131
... // 拨号字段
2232
}
@@ -66,6 +76,24 @@ SSH 用户, 默认使用 root。
6676

6777
客户端版本,默认使用随机值。
6878

79+
#### ciphers
80+
81+
加密算法列表。留空使用 SSH 默认值。
82+
83+
可用值: `aes128-gcm@openssh.com` | `aes256-gcm@openssh.com` | `chacha20-poly1305@openssh.com` | `aes128-ctr` | `aes192-ctr` | `aes256-ctr`
84+
85+
#### macs
86+
87+
MAC(消息认证码)算法列表。留空使用 SSH 默认值。
88+
89+
可用值: `hmac-sha2-256-etm@openssh.com` | `hmac-sha2-512-etm@openssh.com` | `hmac-sha2-256` | `hmac-sha2-512` | `hmac-sha1`
90+
91+
#### key_exchanges
92+
93+
密钥交换算法列表。留空使用 SSH 默认值。
94+
95+
可用值: `mlkem768x25519-sha256` | `curve25519-sha256` | `ecdh-sha2-nistp256` | `ecdh-sha2-nistp384` | `ecdh-sha2-nistp521` | `diffie-hellman-group14-sha256` | `diffie-hellman-group16-sha512` | `diffie-hellman-group-exchange-sha256`
96+
6997
### 拨号字段
7098

7199
参阅 [拨号字段](/zh/configuration/shared/dial/)

option/ssh.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,7 @@ type SSHOutboundOptions struct {
1313
HostKey badoption.Listable[string] `json:"host_key,omitempty"`
1414
HostKeyAlgorithms badoption.Listable[string] `json:"host_key_algorithms,omitempty"`
1515
ClientVersion string `json:"client_version,omitempty"`
16+
Ciphers badoption.Listable[string] `json:"ciphers,omitempty"`
17+
MACs badoption.Listable[string] `json:"macs,omitempty"`
18+
KeyExchanges badoption.Listable[string] `json:"key_exchanges,omitempty"`
1619
}

protocol/ssh/outbound.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ type Outbound struct {
4343
hostKey []ssh.PublicKey
4444
hostKeyAlgorithms []string
4545
clientVersion string
46+
ciphers []string
47+
macs []string
48+
keyExchanges []string
4649
authMethod []ssh.AuthMethod
4750
clientAccess sync.Mutex
4851
clientConn net.Conn
@@ -63,6 +66,9 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
6366
user: options.User,
6467
hostKeyAlgorithms: options.HostKeyAlgorithms,
6568
clientVersion: options.ClientVersion,
69+
ciphers: options.Ciphers,
70+
macs: options.MACs,
71+
keyExchanges: options.KeyExchanges,
6672
}
6773
if outbound.serverAddr.Port == 0 {
6874
outbound.serverAddr.Port = 22
@@ -121,6 +127,29 @@ func randomVersion() string {
121127
return version
122128
}
123129

130+
func validateAlgorithms(userAlgs []string, supported []string, insecure []string, algType string) ([]string, error) {
131+
if len(userAlgs) == 0 {
132+
return nil, nil
133+
}
134+
normalized := make([]string, len(userAlgs))
135+
for i, alg := range userAlgs {
136+
normalized[i] = strings.ToLower(alg)
137+
}
138+
allAlgs := make(map[string]bool)
139+
for _, alg := range supported {
140+
allAlgs[alg] = true
141+
}
142+
for _, alg := range insecure {
143+
allAlgs[alg] = true
144+
}
145+
for _, alg := range normalized {
146+
if !allAlgs[alg] {
147+
return nil, E.New("unknown ", algType, ": ", alg)
148+
}
149+
}
150+
return normalized, nil
151+
}
152+
124153
func (s *Outbound) connect() (*ssh.Client, error) {
125154
if s.client != nil {
126155
return s.client, nil
@@ -137,6 +166,23 @@ func (s *Outbound) connect() (*ssh.Client, error) {
137166
if err != nil {
138167
return nil, err
139168
}
169+
supported := ssh.SupportedAlgorithms()
170+
insecure := ssh.InsecureAlgorithms()
171+
ciphers, err := validateAlgorithms(s.ciphers, supported.Ciphers, insecure.Ciphers, "cipher")
172+
if err != nil {
173+
conn.Close()
174+
return nil, err
175+
}
176+
macs, err := validateAlgorithms(s.macs, supported.MACs, insecure.MACs, "mac")
177+
if err != nil {
178+
conn.Close()
179+
return nil, err
180+
}
181+
keyExchanges, err := validateAlgorithms(s.keyExchanges, supported.KeyExchanges, insecure.KeyExchanges, "key exchange")
182+
if err != nil {
183+
conn.Close()
184+
return nil, err
185+
}
140186
config := &ssh.ClientConfig{
141187
User: s.user,
142188
Auth: s.authMethod,
@@ -154,6 +200,11 @@ func (s *Outbound) connect() (*ssh.Client, error) {
154200
}
155201
return E.New("host key mismatch, server send ", key.Type(), " ", base64.StdEncoding.EncodeToString(serverKey))
156202
},
203+
Config: ssh.Config{
204+
Ciphers: ciphers,
205+
MACs: macs,
206+
KeyExchanges: keyExchanges,
207+
},
157208
}
158209
clientConn, chans, reqs, err := ssh.NewClientConn(conn, s.serverAddr.Addr.String(), config)
159210
if err != nil {

protocol/ssh/outbound_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package ssh
2+
3+
import (
4+
"testing"
5+
6+
"golang.org/x/crypto/ssh"
7+
)
8+
9+
func TestValidateAlgorithms(t *testing.T) {
10+
supported := ssh.SupportedAlgorithms()
11+
insecure := ssh.InsecureAlgorithms()
12+
13+
t.Run("empty list returns nil", func(t *testing.T) {
14+
result, err := validateAlgorithms(nil, supported.Ciphers, insecure.Ciphers, "cipher")
15+
if err != nil {
16+
t.Fatalf("unexpected error: %v", err)
17+
}
18+
if result != nil {
19+
t.Fatalf("expected nil, got %v", result)
20+
}
21+
})
22+
23+
t.Run("valid supported ciphers", func(t *testing.T) {
24+
input := []string{"aes128-gcm@openssh.com", "chacha20-poly1305@openssh.com"}
25+
result, err := validateAlgorithms(input, supported.Ciphers, insecure.Ciphers, "cipher")
26+
if err != nil {
27+
t.Fatalf("unexpected error: %v", err)
28+
}
29+
if len(result) != 2 {
30+
t.Fatalf("expected 2 results, got %d", len(result))
31+
}
32+
if result[0] != "aes128-gcm@openssh.com" || result[1] != "chacha20-poly1305@openssh.com" {
33+
t.Fatalf("unexpected result: %v", result)
34+
}
35+
})
36+
37+
t.Run("valid insecure ciphers", func(t *testing.T) {
38+
input := []string{"aes128-cbc", "3des-cbc"}
39+
result, err := validateAlgorithms(input, supported.Ciphers, insecure.Ciphers, "cipher")
40+
if err != nil {
41+
t.Fatalf("unexpected error: %v", err)
42+
}
43+
if len(result) != 2 {
44+
t.Fatalf("expected 2 results, got %d", len(result))
45+
}
46+
})
47+
48+
t.Run("invalid cipher returns error", func(t *testing.T) {
49+
input := []string{"aes128-gcm@openssh.com", "invalid-cipher-123"}
50+
_, err := validateAlgorithms(input, supported.Ciphers, insecure.Ciphers, "cipher")
51+
if err == nil {
52+
t.Fatal("expected error for invalid cipher")
53+
}
54+
expected := "unknown cipher: invalid-cipher-123"
55+
if err.Error() != expected {
56+
t.Fatalf("expected error %q, got %q", expected, err.Error())
57+
}
58+
})
59+
60+
t.Run("case normalization", func(t *testing.T) {
61+
input := []string{"AES128-GCM@OPENSSH.COM"}
62+
result, err := validateAlgorithms(input, supported.Ciphers, insecure.Ciphers, "cipher")
63+
if err != nil {
64+
t.Fatalf("unexpected error: %v", err)
65+
}
66+
if result[0] != "aes128-gcm@openssh.com" {
67+
t.Fatalf("expected lowercase, got %q", result[0])
68+
}
69+
})
70+
71+
t.Run("order preserved", func(t *testing.T) {
72+
input := []string{"aes256-ctr", "aes128-gcm@openssh.com", "chacha20-poly1305@openssh.com"}
73+
result, err := validateAlgorithms(input, supported.Ciphers, insecure.Ciphers, "cipher")
74+
if err != nil {
75+
t.Fatalf("unexpected error: %v", err)
76+
}
77+
if result[0] != "aes256-ctr" || result[1] != "aes128-gcm@openssh.com" || result[2] != "chacha20-poly1305@openssh.com" {
78+
t.Fatalf("order not preserved: %v", result)
79+
}
80+
})
81+
82+
t.Run("valid MACs", func(t *testing.T) {
83+
input := []string{"hmac-sha2-256-etm@openssh.com"}
84+
result, err := validateAlgorithms(input, supported.MACs, insecure.MACs, "mac")
85+
if err != nil {
86+
t.Fatalf("unexpected error: %v", err)
87+
}
88+
if len(result) != 1 || result[0] != "hmac-sha2-256-etm@openssh.com" {
89+
t.Fatalf("unexpected result: %v", result)
90+
}
91+
})
92+
93+
t.Run("invalid MAC returns error", func(t *testing.T) {
94+
input := []string{"invalid-mac"}
95+
_, err := validateAlgorithms(input, supported.MACs, insecure.MACs, "mac")
96+
if err == nil {
97+
t.Fatal("expected error for invalid mac")
98+
}
99+
expected := "unknown mac: invalid-mac"
100+
if err.Error() != expected {
101+
t.Fatalf("expected error %q, got %q", expected, err.Error())
102+
}
103+
})
104+
105+
t.Run("valid key exchanges", func(t *testing.T) {
106+
input := []string{"curve25519-sha256"}
107+
result, err := validateAlgorithms(input, supported.KeyExchanges, insecure.KeyExchanges, "key exchange")
108+
if err != nil {
109+
t.Fatalf("unexpected error: %v", err)
110+
}
111+
if len(result) != 1 || result[0] != "curve25519-sha256" {
112+
t.Fatalf("unexpected result: %v", result)
113+
}
114+
})
115+
116+
t.Run("invalid key exchange returns error", func(t *testing.T) {
117+
input := []string{"invalid-kex"}
118+
_, err := validateAlgorithms(input, supported.KeyExchanges, insecure.KeyExchanges, "key exchange")
119+
if err == nil {
120+
t.Fatal("expected error for invalid key exchange")
121+
}
122+
expected := "unknown key exchange: invalid-kex"
123+
if err.Error() != expected {
124+
t.Fatalf("expected error %q, got %q", expected, err.Error())
125+
}
126+
})
127+
128+
t.Run("duplicates passed through", func(t *testing.T) {
129+
input := []string{"aes128-gcm@openssh.com", "aes128-gcm@openssh.com"}
130+
result, err := validateAlgorithms(input, supported.Ciphers, insecure.Ciphers, "cipher")
131+
if err != nil {
132+
t.Fatalf("unexpected error: %v", err)
133+
}
134+
if len(result) != 2 {
135+
t.Fatalf("expected 2 results (duplicates preserved), got %d", len(result))
136+
}
137+
})
138+
}

0 commit comments

Comments
 (0)