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