返回 DeepSeek-Reasonix
knownhosts.go
根目录 / internal / remote / knownhosts.go
1 package remote
2
3 import (
4 "bufio"
5 "bytes"
6 "context"
7 "errors"
8 "fmt"
9 "net"
10 "os"
11 "path/filepath"
12 "strings"
13 "sync"
14
15 "golang.org/x/crypto/ssh"
16 "golang.org/x/crypto/ssh/knownhosts"
17 )
18
19 // HostKeyQuestion describes a first-seen (TOFU) host key awaiting the user's
20 // decision.
21 type HostKeyQuestion struct {
22 Host string // display label (user@host:port or alias)
23 Address string // the network address that presented the key
24 KeyType string // e.g. "ssh-ed25519"
25 Fingerprint string // ssh.FingerprintSHA256(key)
26 }
27
28 // KnownHostLocation identifies the OpenSSH record that conflicts with a
29 // presented host key. It is intentionally structured so desktop clients can
30 // keep machine-local paths out of the primary error message while still
31 // exposing the exact record in an explicit security-details view.
32 type KnownHostLocation struct {
33 Filename string
34 Line int
35 }
36
37 // HostKeyMismatchError describes a presented key that contradicts an existing
38 // known_hosts record. It unwraps to ErrHostKeyMismatch so callers can retain
39 // the existing fail-closed classification without parsing error strings.
40 type HostKeyMismatchError struct {
41 Host string
42 PresentedFingerprint string
43 Locations []KnownHostLocation
44 }
45
46 func (e *HostKeyMismatchError) Error() string {
47 var b strings.Builder
48 fmt.Fprintf(&b, "%s for %s: presented %s; known_hosts records a different key",
49 ErrHostKeyMismatch, e.Host, e.PresentedFingerprint)
50 for _, location := range e.Locations {
51 if location.Filename != "" {
52 fmt.Fprintf(&b, " (%s:%d)", location.Filename, location.Line)
53 }
54 }
55 return b.String()
56 }
57
58 func (e *HostKeyMismatchError) Unwrap() error { return ErrHostKeyMismatch }
59
60 // HostKeyPrompt is called for an unknown host key. Returning (true, nil)
61 // accepts and persists it (trust on first use); (false, nil) rejects; a
62 // non-nil error aborts the dial. A nil prompt means strict mode: unknown hosts
63 // are rejected.
64 type HostKeyPrompt func(ctx context.Context, q HostKeyQuestion) (accept bool, err error)
65
66 // HostKeyPolicy verifies presented host keys against the user's OpenSSH
67 // known_hosts files (read-only) and a Reasonix-managed file (read-write, TOFU).
68 type HostKeyPolicy struct {
69 // SystemKnownHosts are OpenSSH known_hosts files consulted read-only.
70 // Empty => [~/.ssh/known_hosts, ~/.ssh/known_hosts2] when they exist.
71 SystemKnownHosts []string
72 // ManagedPath is the Reasonix-managed known_hosts file that accepted TOFU
73 // keys are appended to. Empty => config.RemoteKnownHostsPath().
74 ManagedPath string
75 // Prompt decides unknown (first-seen) keys. Nil => strict reject.
76 Prompt HostKeyPrompt
77 // Verified observes a key only after the known_hosts check (and, for TOFU,
78 // the user's acceptance and durable append) succeeded. It lets an assembly
79 // layer bind higher-level capabilities to the peer actually authenticated by
80 // this transport without weakening HostKeyCallback authority.
81 Verified func(HostKeyQuestion)
82
83 mu sync.Mutex // serializes appends to ManagedPath
84 }
85
86 // Callback builds an ssh.HostKeyCallback enforcing this policy for host (the
87 // display label used in prompts). ctx bounds any interactive prompt.
88 func (p *HostKeyPolicy) Callback(ctx context.Context, host string) (ssh.HostKeyCallback, error) {
89 base, managed, err := p.loadCallback()
90 if err != nil {
91 return nil, err
92 }
93
94 return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
95 if base != nil {
96 err := base(hostname, remote, key)
97 if err == nil {
98 p.notifyVerified(host, hostname, remote, key)
99 return nil
100 }
101 var keyErr *knownhosts.KeyError
102 if !errors.As(err, &keyErr) {
103 return err
104 }
105 if len(keyErr.Want) > 0 {
106 // A different key is on record for this host: hard fail, never
107 // promptable. Name the file:line so the user can inspect it.
108 return newHostKeyMismatchError(host, ssh.FingerprintSHA256(key), keyErr)
109 }
110 // len(Want)==0 => host unknown. Fall through to TOFU.
111 }
112 if err := p.tofu(ctx, host, hostname, remote, key, managed); err != nil {
113 return err
114 }
115 p.notifyVerified(host, hostname, remote, key)
116 return nil
117 }, nil
118 }
119
120 func (p *HostKeyPolicy) notifyVerified(host, hostname string, remoteAddr net.Addr, key ssh.PublicKey) {
121 if p == nil || p.Verified == nil || key == nil {
122 return
123 }
124 address := hostname
125 if remoteAddr != nil && strings.TrimSpace(remoteAddr.String()) != "" {
126 address = remoteAddr.String()
127 }
128 p.Verified(HostKeyQuestion{
129 Host: host, Address: address, KeyType: key.Type(), Fingerprint: ssh.FingerprintSHA256(key),
130 })
131 }
132
133 // HostKeyAlgorithms returns host-key algorithms in negotiation order,
134 // preferring algorithms compatible with ordinary host identities already
135 // recorded for hostname. Certificate-authority records are deliberately not
136 // treated as host keys: the CA algorithm does not describe the certified host
137 // key. The strict callback remains the authority for every negotiated key.
138 func (p *HostKeyPolicy) HostKeyAlgorithms(hostname string, remote net.Addr) ([]string, error) {
139 base, _, err := p.loadCallback()
140 if err != nil || base == nil {
141 return nil, err
142 }
143 err = base(hostname, remote, hostKeyLookupProbe{})
144 if err == nil {
145 return nil, nil
146 }
147 var keyErr *knownhosts.KeyError
148 if !errors.As(err, &keyErr) {
149 return nil, err
150 }
151 if len(keyErr.Want) == 0 {
152 return nil, nil
153 }
154
155 preferred := make(map[string]bool, len(keyErr.Want))
156 for _, known := range keyErr.Want {
157 if known.Key == nil {
158 continue
159 }
160 marker, err := knownHostMarker(known)
161 if err != nil {
162 return nil, err
163 }
164 if marker != "" {
165 continue
166 }
167 keyType := known.Key.Type()
168 preferred[keyType] = true
169 switch keyType {
170 case ssh.KeyAlgoRSA:
171 // An ssh-rsa public key can use the SHA-2 signature algorithms;
172 preferred[ssh.KeyAlgoRSASHA512] = true
173 preferred[ssh.KeyAlgoRSASHA256] = true
174 case ssh.CertAlgoRSAv01:
175 // RSA host certificates likewise support SHA-2 signature
176 // algorithms even though their public key format is ssh-rsa.
177 preferred[ssh.CertAlgoRSASHA512v01] = true
178 preferred[ssh.CertAlgoRSASHA256v01] = true
179 }
180 }
181
182 candidates := hostKeyAlgorithmCandidates()
183 ordered := make([]string, 0, len(candidates))
184 for _, algorithm := range candidates {
185 if preferred[algorithm] {
186 ordered = append(ordered, algorithm)
187 }
188 }
189 if len(ordered) == 0 {
190 return nil, nil
191 }
192 for _, algorithm := range candidates {
193 if !preferred[algorithm] {
194 ordered = append(ordered, algorithm)
195 }
196 }
197 return ordered, nil
198 }
199
200 // hostKeyAlgorithmCandidates preserves the algorithms in the Go SSH default
201 // policy while keeping secure algorithms ahead of legacy fallbacks. Legacy
202 // algorithms are only promoted when their exact public key format is already
203 // recorded; the host-key callback must still verify the key material.
204 func hostKeyAlgorithmCandidates() []string {
205 secure := ssh.SupportedAlgorithms().HostKeys
206 legacy := ssh.InsecureAlgorithms().HostKeys
207 algorithms := make([]string, 0, len(secure)+len(legacy))
208 seen := make(map[string]bool, cap(algorithms))
209 for _, algorithm := range append(secure, legacy...) {
210 if !seen[algorithm] {
211 seen[algorithm] = true
212 algorithms = append(algorithms, algorithm)
213 }
214 }
215 return algorithms
216 }
217
218 // knownHostMarker reads the original matching record so @cert-authority and
219 // @revoked entries cannot be mistaken for ordinary host identities. KnownKey
220 // exposes the exact file and line selected by knownhosts.New; ParseKnownHosts
221 // supplies OpenSSH marker semantics without duplicating its parser.
222 func knownHostMarker(known knownhosts.KnownKey) (string, error) {
223 if known.Filename == "" || known.Line <= 0 {
224 return "", fmt.Errorf("known_hosts record has no source location")
225 }
226 f, err := os.Open(known.Filename)
227 if err != nil {
228 return "", fmt.Errorf("open known_hosts record %s:%d: %w", known.Filename, known.Line, err)
229 }
230 defer f.Close()
231
232 scanner := bufio.NewScanner(f)
233 for line := 1; scanner.Scan(); line++ {
234 if line != known.Line {
235 continue
236 }
237 marker, _, key, _, _, err := ssh.ParseKnownHosts(scanner.Bytes())
238 if err != nil {
239 return "", fmt.Errorf("parse known_hosts record %s:%d: %w", known.Filename, known.Line, err)
240 }
241 if key == nil || known.Key == nil || !bytes.Equal(key.Marshal(), known.Key.Marshal()) {
242 return "", fmt.Errorf("known_hosts record changed while connecting: %s:%d", known.Filename, known.Line)
243 }
244 return marker, nil
245 }
246 if err := scanner.Err(); err != nil {
247 return "", fmt.Errorf("read known_hosts record %s:%d: %w", known.Filename, known.Line, err)
248 }
249 return "", fmt.Errorf("known_hosts record no longer exists: %s:%d", known.Filename, known.Line)
250 }
251
252 // hostKeyLookupProbe deliberately cannot equal a parsed OpenSSH public key.
253 // Passing it through knownhosts.New lets us reuse the library's exact hostname,
254 // wildcard, hashed-host, port, and file matching and inspect KeyError.Want.
255 type hostKeyLookupProbe struct{}
256
257 func (hostKeyLookupProbe) Type() string { return "reasonix-host-key-lookup-probe" }
258 func (hostKeyLookupProbe) Marshal() []byte { return []byte("reasonix-host-key-lookup-probe") }
259 func (hostKeyLookupProbe) Verify([]byte, *ssh.Signature) error {
260 return fmt.Errorf("host-key lookup probe cannot verify signatures")
261 }
262
263 func (p *HostKeyPolicy) loadCallback() (ssh.HostKeyCallback, string, error) {
264 files := p.systemFiles()
265 managed := p.managedPath()
266 if managed != "" {
267 if err := os.MkdirAll(filepath.Dir(managed), 0o700); err != nil {
268 return nil, "", err
269 }
270 // knownhosts.New requires each file to exist; create an empty managed
271 // file on first use.
272 if _, err := os.Stat(managed); os.IsNotExist(err) {
273 if err := os.WriteFile(managed, nil, 0o600); err != nil {
274 return nil, "", err
275 }
276 }
277 files = append(files, managed)
278 }
279
280 var base ssh.HostKeyCallback
281 if len(files) > 0 {
282 var err error
283 base, err = knownhosts.New(files...)
284 if err != nil {
285 return nil, "", fmt.Errorf("load known_hosts: %w", err)
286 }
287 }
288 return base, managed, nil
289 }
290
291 func (p *HostKeyPolicy) tofu(ctx context.Context, host, hostname string, remote net.Addr, key ssh.PublicKey, managed string) error {
292 if p.Prompt == nil {
293 return fmt.Errorf("%w for %s: unknown host key %s (no confirmation available)",
294 ErrHostKeyRejected, host, ssh.FingerprintSHA256(key))
295 }
296 accept, err := p.Prompt(ctx, HostKeyQuestion{
297 Host: host,
298 Address: remote.String(),
299 KeyType: key.Type(),
300 Fingerprint: ssh.FingerprintSHA256(key),
301 })
302 if err != nil {
303 return err
304 }
305 if !accept {
306 return fmt.Errorf("%w for %s", ErrHostKeyRejected, host)
307 }
308 if managed == "" {
309 return nil // accepted for this session only
310 }
311 return p.appendManaged(managed, hostname, remote, key)
312 }
313
314 func (p *HostKeyPolicy) appendManaged(managed, hostname string, remote net.Addr, key ssh.PublicKey) error {
315 p.mu.Lock()
316 defer p.mu.Unlock()
317 addrs := []string{knownhosts.Normalize(hostname)}
318 if remote != nil {
319 if norm := knownhosts.Normalize(remote.String()); norm != addrs[0] {
320 addrs = append(addrs, norm)
321 }
322 }
323 line := knownhosts.Line(addrs, key)
324 f, err := os.OpenFile(managed, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
325 if err != nil {
326 return err
327 }
328 defer f.Close()
329 if _, err := f.WriteString(strings.TrimRight(line, "\n") + "\n"); err != nil {
330 return err
331 }
332 return nil
333 }
334
335 func (p *HostKeyPolicy) systemFiles() []string {
336 if len(p.SystemKnownHosts) > 0 {
337 out := make([]string, 0, len(p.SystemKnownHosts))
338 for _, f := range p.SystemKnownHosts {
339 if f = expandHome(f); fileExists(f) {
340 out = append(out, f)
341 }
342 }
343 return out
344 }
345 home, err := os.UserHomeDir()
346 if err != nil {
347 return nil
348 }
349 var out []string
350 for _, name := range []string{"known_hosts", "known_hosts2"} {
351 p := filepath.Join(home, ".ssh", name)
352 if fileExists(p) {
353 out = append(out, p)
354 }
355 }
356 return out
357 }
358
359 func (p *HostKeyPolicy) managedPath() string {
360 if p.ManagedPath != "" {
361 return p.ManagedPath
362 }
363 return defaultManagedKnownHosts()
364 }
365
366 func newHostKeyMismatchError(host, presented string, e *knownhosts.KeyError) error {
367 locations := make([]KnownHostLocation, 0, len(e.Want))
368 for _, k := range e.Want {
369 locations = append(locations, KnownHostLocation{Filename: k.Filename, Line: k.Line})
370 }
371 return &HostKeyMismatchError{Host: host, PresentedFingerprint: presented, Locations: locations}
372 }
373
374 func fileExists(path string) bool {
375 fi, err := os.Stat(path)
376 return err == nil && !fi.IsDir()
377 }
378
378 lines GO