返回 DeepSeek-Reasonix
knownhosts_test.go
根目录 / internal / remote / knownhosts_test.go
1 package remote
2
3 import (
4 "context"
5 "crypto/ecdsa"
6 "crypto/ed25519"
7 "crypto/elliptic"
8 "crypto/rand"
9 "crypto/rsa"
10 "errors"
11 "fmt"
12 "net"
13 "os"
14 "path/filepath"
15 "strings"
16 "testing"
17 "time"
18
19 "golang.org/x/crypto/ssh"
20 "golang.org/x/crypto/ssh/knownhosts"
21
22 "reasonix/internal/remote/sshtest"
23 )
24
25 func TestNewSSHClientPrefersRecordedHostKeyAlgorithm(t *testing.T) {
26 knownED25519 := generateED25519Signer(t)
27 otherECDSA := generateECDSASigner(t)
28 server := sshtest.Start(t, sshtest.Options{
29 HostKeys: []ssh.Signer{otherECDSA, knownED25519},
30 })
31 systemPath := filepath.Join(t.TempDir(), "known_hosts")
32 managedPath := filepath.Join(t.TempDir(), "known_hosts")
33 writeKnownHost(t, systemPath, server.Addr, knownED25519.PublicKey())
34
35 policy := &HostKeyPolicy{
36 SystemKnownHosts: []string{systemPath},
37 ManagedPath: managedPath,
38 Prompt: func(context.Context, HostKeyQuestion) (bool, error) {
39 t.Fatal("known multi-algorithm host must not prompt")
40 return false, nil
41 },
42 }
43
44 client := connectTestServer(t, server, policy)
45 defer client.Close()
46 }
47
48 func TestNewSSHClientReconnectsToRecordedLegacyRSAHost(t *testing.T) {
49 privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
50 if err != nil {
51 t.Fatal(err)
52 }
53 signer, err := ssh.NewSignerFromKey(privateKey)
54 if err != nil {
55 t.Fatal(err)
56 }
57 restricted, err := ssh.NewSignerWithAlgorithms(signer.(ssh.AlgorithmSigner), []string{ssh.KeyAlgoRSA})
58 if err != nil {
59 t.Fatal(err)
60 }
61 server := sshtest.Start(t, sshtest.Options{HostKeys: []ssh.Signer{restricted}})
62 prompted := 0
63 policy := &HostKeyPolicy{
64 SystemKnownHosts: []string{filepath.Join(t.TempDir(), "missing")},
65 ManagedPath: filepath.Join(t.TempDir(), "known_hosts"),
66 Prompt: func(context.Context, HostKeyQuestion) (bool, error) {
67 prompted++
68 return true, nil
69 },
70 }
71
72 client := connectTestServer(t, server, policy)
73 _ = client.Close()
74 client = connectTestServer(t, server, policy)
75 defer client.Close()
76 if prompted != 1 {
77 t.Fatalf("prompt count = %d, want 1", prompted)
78 }
79 }
80
81 func TestNewSSHClientPrefersTrustedHostCertificate(t *testing.T) {
82 caSigner := generateED25519Signer(t)
83 hostSigner := generateECDSASigner(t)
84 certificate := &ssh.Certificate{
85 Key: hostSigner.PublicKey(),
86 CertType: ssh.HostCert,
87 ValidPrincipals: []string{"127.0.0.1"},
88 ValidBefore: ssh.CertTimeInfinity,
89 }
90 if err := certificate.SignCert(rand.Reader, caSigner); err != nil {
91 t.Fatal(err)
92 }
93 certificateSigner, err := ssh.NewCertSigner(certificate, hostSigner)
94 if err != nil {
95 t.Fatal(err)
96 }
97 otherED25519 := generateED25519Signer(t)
98 server := sshtest.Start(t, sshtest.Options{
99 HostKeys: []ssh.Signer{otherED25519, certificateSigner},
100 })
101 systemPath := filepath.Join(t.TempDir(), "known_hosts")
102 writeKnownHostAuthority(t, systemPath, server.Addr, caSigner.PublicKey())
103 policy := &HostKeyPolicy{
104 SystemKnownHosts: []string{systemPath},
105 ManagedPath: filepath.Join(t.TempDir(), "known_hosts"),
106 Prompt: func(context.Context, HostKeyQuestion) (bool, error) {
107 t.Fatal("certified host must not prompt")
108 return false, nil
109 },
110 }
111
112 client := connectTestServer(t, server, policy)
113 defer client.Close()
114 }
115
116 func TestHostKeyPolicyRejectsChangedKeyAcrossAlgorithms(t *testing.T) {
117 hostname := "example.test:2222"
118 knownED25519 := generateED25519Signer(t)
119 presentedECDSA := generateECDSASigner(t)
120 systemPath := filepath.Join(t.TempDir(), "known_hosts")
121 managedPath := filepath.Join(t.TempDir(), "known_hosts")
122 writeKnownHost(t, systemPath, hostname, knownED25519.PublicKey())
123
124 prompted := false
125 policy := &HostKeyPolicy{
126 SystemKnownHosts: []string{systemPath},
127 ManagedPath: managedPath,
128 Prompt: func(context.Context, HostKeyQuestion) (bool, error) {
129 prompted = true
130 return true, nil
131 },
132 }
133 callback, err := policy.Callback(context.Background(), "example")
134 if err != nil {
135 t.Fatal(err)
136 }
137 err = callback(hostname, &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 2222}, presentedECDSA.PublicKey())
138 if !errors.Is(err, ErrHostKeyMismatch) {
139 t.Fatalf("error = %v, want ErrHostKeyMismatch", err)
140 }
141 if prompted {
142 t.Fatal("cross-algorithm mismatch must not be promptable")
143 }
144 }
145
146 func TestHostKeyPolicyRejectsChangedKeyOfSameAlgorithm(t *testing.T) {
147 hostname := "example.test:2222"
148 knownKey := generateED25519Signer(t)
149 presentedKey := generateED25519Signer(t)
150 systemPath := filepath.Join(t.TempDir(), "known_hosts")
151 managedPath := filepath.Join(t.TempDir(), "known_hosts")
152 writeKnownHost(t, systemPath, hostname, knownKey.PublicKey())
153
154 prompted := false
155 policy := &HostKeyPolicy{
156 SystemKnownHosts: []string{systemPath},
157 ManagedPath: managedPath,
158 Prompt: func(context.Context, HostKeyQuestion) (bool, error) {
159 prompted = true
160 return true, nil
161 },
162 }
163 callback, err := policy.Callback(context.Background(), "example")
164 if err != nil {
165 t.Fatal(err)
166 }
167 err = callback(hostname, &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 2222}, presentedKey.PublicKey())
168 if !errors.Is(err, ErrHostKeyMismatch) {
169 t.Fatalf("error = %v, want ErrHostKeyMismatch", err)
170 }
171 if prompted {
172 t.Fatal("same-algorithm mismatch must not be promptable")
173 }
174 }
175
176 func TestHostKeyPolicyObservesOnlyVerifiedPeer(t *testing.T) {
177 hostname := "example.test:2222"
178 knownKey := generateED25519Signer(t)
179 changedKey := generateED25519Signer(t)
180 systemPath := filepath.Join(t.TempDir(), "known_hosts")
181 writeKnownHost(t, systemPath, hostname, knownKey.PublicKey())
182 var verified []HostKeyQuestion
183 policy := &HostKeyPolicy{
184 SystemKnownHosts: []string{systemPath},
185 ManagedPath: filepath.Join(t.TempDir(), "managed_known_hosts"),
186 Verified: func(q HostKeyQuestion) {
187 verified = append(verified, q)
188 },
189 }
190 callback, err := policy.Callback(context.Background(), "saved-host")
191 if err != nil {
192 t.Fatal(err)
193 }
194 remoteAddr := &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 2222}
195 if err := callback(hostname, remoteAddr, knownKey.PublicKey()); err != nil {
196 t.Fatal(err)
197 }
198 if len(verified) != 1 || verified[0].Fingerprint != ssh.FingerprintSHA256(knownKey.PublicKey()) || verified[0].Host != "saved-host" {
199 t.Fatalf("verified observations = %+v", verified)
200 }
201 if err := callback(hostname, remoteAddr, changedKey.PublicKey()); !errors.Is(err, ErrHostKeyMismatch) {
202 t.Fatalf("changed key error = %v", err)
203 }
204 if len(verified) != 1 {
205 t.Fatalf("mismatched key was observed as verified: %+v", verified)
206 }
207 }
208
209 func writeKnownHost(t *testing.T, path, hostname string, key ssh.PublicKey) {
210 t.Helper()
211 line := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key)
212 if err := os.WriteFile(path, []byte(line+"\n"), 0o600); err != nil {
213 t.Fatal(err)
214 }
215 }
216
217 func writeKnownHostAuthority(t *testing.T, path, hostname string, key ssh.PublicKey) {
218 t.Helper()
219 keyText := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key)))
220 line := fmt.Sprintf("@cert-authority %s %s\n", knownhosts.Normalize(hostname), keyText)
221 if err := os.WriteFile(path, []byte(line), 0o600); err != nil {
222 t.Fatal(err)
223 }
224 }
225
226 func connectTestServer(t *testing.T, server *sshtest.Server, policy *HostKeyPolicy) *ssh.Client {
227 t.Helper()
228 _, hostName, port, err := ParseTarget(server.Addr)
229 if err != nil {
230 t.Fatal(err)
231 }
232 conn, err := net.DialTimeout("tcp", server.Addr, time.Second)
233 if err != nil {
234 t.Fatal(err)
235 }
236 client, err := newSSHClient(context.Background(), conn, ResolvedHost{
237 Name: server.Addr, HostName: hostName, Port: port, User: "test",
238 }, &AuthOptions{DisableAgent: true}, policy, time.Second)
239 if err != nil {
240 t.Fatalf("connect to test SSH server: %v", err)
241 }
242 return client
243 }
244
245 func generateED25519Signer(t *testing.T) ssh.Signer {
246 t.Helper()
247 _, privateKey, err := ed25519.GenerateKey(rand.Reader)
248 if err != nil {
249 t.Fatal(err)
250 }
251 signer, err := ssh.NewSignerFromKey(privateKey)
252 if err != nil {
253 t.Fatal(err)
254 }
255 return signer
256 }
257
258 func generateECDSASigner(t *testing.T) ssh.Signer {
259 t.Helper()
260 privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
261 if err != nil {
262 t.Fatal(err)
263 }
264 signer, err := ssh.NewSignerFromKey(privateKey)
265 if err != nil {
266 t.Fatal(err)
267 }
268 return signer
269 }
270
270 lines GO