main
1// Package knownhosts is a thin wrapper around golang.org/x/crypto/ssh/knownhosts,
2// adding the ability to obtain the list of host key algorithms for a known host.
3package knownhosts
4
5import (
6 "bufio"
7 "bytes"
8 "encoding/base64"
9 "errors"
10 "fmt"
11 "io"
12 "net"
13 "os"
14 "sort"
15 "strings"
16
17 "golang.org/x/crypto/ssh"
18 xknownhosts "golang.org/x/crypto/ssh/knownhosts"
19)
20
21// HostKeyDB wraps logic in golang.org/x/crypto/ssh/knownhosts with additional
22// behaviors, such as the ability to perform host key/algorithm lookups from
23// known_hosts entries.
24type HostKeyDB struct {
25 callback ssh.HostKeyCallback
26 isCert map[string]bool // keyed by "filename:line"
27 isWildcard map[string]bool // keyed by "filename:line"
28}
29
30// NewDB creates a HostKeyDB from the given OpenSSH known_hosts file(s). It
31// reads and parses the provided files one additional time (beyond logic in
32// golang.org/x/crypto/ssh/knownhosts) in order to:
33//
34// - Handle CA lines properly and return ssh.CertAlgo* values when calling the
35// HostKeyAlgorithms method, for use in ssh.ClientConfig.HostKeyAlgorithms
36// - Allow * wildcards in hostnames to match on non-standard ports, providing
37// a workaround for https://github.com/golang/go/issues/52056 in order to
38// align with OpenSSH's wildcard behavior
39//
40// When supplying multiple files, their order does not matter.
41func NewDB(files ...string) (*HostKeyDB, error) {
42 cb, err := xknownhosts.New(files...)
43 if err != nil {
44 return nil, err
45 }
46 hkdb := &HostKeyDB{
47 callback: cb,
48 isCert: make(map[string]bool),
49 isWildcard: make(map[string]bool),
50 }
51
52 // Re-read each file a single time, looking for @cert-authority lines. The
53 // logic for reading the file is designed to mimic hostKeyDB.Read from
54 // golang.org/x/crypto/ssh/knownhosts
55 for _, filename := range files {
56 f, err := os.Open(filename)
57 if err != nil {
58 return nil, err
59 }
60 defer f.Close()
61 scanner := bufio.NewScanner(f)
62 lineNum := 0
63 for scanner.Scan() {
64 lineNum++
65 line := scanner.Bytes()
66 line = bytes.TrimSpace(line)
67 // Does the line start with "@cert-authority" followed by whitespace?
68 if len(line) > 15 && bytes.HasPrefix(line, []byte("@cert-authority")) && (line[15] == ' ' || line[15] == '\t') {
69 mapKey := fmt.Sprintf("%s:%d", filename, lineNum)
70 hkdb.isCert[mapKey] = true
71 line = bytes.TrimSpace(line[16:])
72 }
73 // truncate line to just the host pattern field
74 if i := bytes.IndexAny(line, "\t "); i >= 0 {
75 line = line[:i]
76 }
77 // Does the host pattern contain a * wildcard and no specific port?
78 if i := bytes.IndexRune(line, '*'); i >= 0 && !bytes.Contains(line[i:], []byte("]:")) {
79 mapKey := fmt.Sprintf("%s:%d", filename, lineNum)
80 hkdb.isWildcard[mapKey] = true
81 }
82 }
83 if err := scanner.Err(); err != nil {
84 return nil, fmt.Errorf("knownhosts: %s:%d: %w", filename, lineNum, err)
85 }
86 }
87 return hkdb, nil
88}
89
90// HostKeyCallback returns an ssh.HostKeyCallback. This can be used directly in
91// ssh.ClientConfig.HostKeyCallback, as shown in the example for NewDB.
92// Alternatively, you can wrap it with an outer callback to potentially handle
93// appending a new entry to the known_hosts file; see example in WriteKnownHost.
94func (hkdb *HostKeyDB) HostKeyCallback() ssh.HostKeyCallback {
95 // Either NewDB found no wildcard host patterns, or hkdb was created from
96 // HostKeyCallback.ToDB in which case we didn't scan known_hosts for them:
97 // return the callback (which came from x/crypto/ssh/knownhosts) as-is
98 if len(hkdb.isWildcard) == 0 {
99 return hkdb.callback
100 }
101
102 // If we scanned for wildcards and found at least one, return a wrapped
103 // callback with extra behavior: if the host lookup found no matches, and the
104 // host arg had a non-standard port, re-do the lookup on standard port 22. If
105 // that second call returns a *xknownhosts.KeyError, filter down any resulting
106 // Want keys to known wildcard entries.
107 f := func(hostname string, remote net.Addr, key ssh.PublicKey) error {
108 callbackErr := hkdb.callback(hostname, remote, key)
109 if callbackErr == nil || IsHostKeyChanged(callbackErr) { // hostname has known_host entries as-is
110 return callbackErr
111 }
112 justHost, port, splitErr := net.SplitHostPort(hostname)
113 if splitErr != nil || port == "" || port == "22" { // hostname already using standard port
114 return callbackErr
115 }
116 // If we reach here, the port was non-standard and no known_host entries
117 // were found for the non-standard port. Try again with standard port.
118 if tcpAddr, ok := remote.(*net.TCPAddr); ok && tcpAddr.Port != 22 {
119 remote = &net.TCPAddr{
120 IP: tcpAddr.IP,
121 Port: 22,
122 Zone: tcpAddr.Zone,
123 }
124 }
125 callbackErr = hkdb.callback(justHost+":22", remote, key)
126 var keyErr *xknownhosts.KeyError
127 if errors.As(callbackErr, &keyErr) && len(keyErr.Want) > 0 {
128 wildcardKeys := make([]xknownhosts.KnownKey, 0, len(keyErr.Want))
129 for _, wantKey := range keyErr.Want {
130 if hkdb.isWildcard[fmt.Sprintf("%s:%d", wantKey.Filename, wantKey.Line)] {
131 wildcardKeys = append(wildcardKeys, wantKey)
132 }
133 }
134 callbackErr = &xknownhosts.KeyError{
135 Want: wildcardKeys,
136 }
137 }
138 return callbackErr
139 }
140 return ssh.HostKeyCallback(f)
141}
142
143// PublicKey wraps ssh.PublicKey with an additional field, to identify
144// whether the key corresponds to a certificate authority.
145type PublicKey struct {
146 ssh.PublicKey
147 Cert bool
148}
149
150// HostKeys returns a slice of known host public keys for the supplied host:port
151// found in the known_hosts file(s), or an empty slice if the host is not
152// already known. For hosts that have multiple known_hosts entries (for
153// different key types), the result will be sorted by known_hosts filename and
154// line number.
155// If hkdb was originally created by calling NewDB, the Cert boolean field of
156// each result entry reports whether the key corresponded to a @cert-authority
157// line. If hkdb was NOT obtained from NewDB, then Cert will always be false.
158func (hkdb *HostKeyDB) HostKeys(hostWithPort string) (keys []PublicKey) {
159 var keyErr *xknownhosts.KeyError
160 placeholderAddr := &net.TCPAddr{IP: []byte{0, 0, 0, 0}}
161 placeholderPubKey := &fakePublicKey{}
162 var kkeys []xknownhosts.KnownKey
163 callback := hkdb.HostKeyCallback()
164 if hkcbErr := callback(hostWithPort, placeholderAddr, placeholderPubKey); errors.As(hkcbErr, &keyErr) {
165 kkeys = append(kkeys, keyErr.Want...)
166 knownKeyLess := func(i, j int) bool {
167 if kkeys[i].Filename < kkeys[j].Filename {
168 return true
169 }
170 return (kkeys[i].Filename == kkeys[j].Filename && kkeys[i].Line < kkeys[j].Line)
171 }
172 sort.Slice(kkeys, knownKeyLess)
173 keys = make([]PublicKey, len(kkeys))
174 for n := range kkeys {
175 keys[n] = PublicKey{
176 PublicKey: kkeys[n].Key,
177 }
178 if len(hkdb.isCert) > 0 {
179 keys[n].Cert = hkdb.isCert[fmt.Sprintf("%s:%d", kkeys[n].Filename, kkeys[n].Line)]
180 }
181 }
182 }
183 return keys
184}
185
186// HostKeyAlgorithms returns a slice of host key algorithms for the supplied
187// host:port found in the known_hosts file(s), or an empty slice if the host
188// is not already known. The result may be used in ssh.ClientConfig's
189// HostKeyAlgorithms field, either as-is or after filtering (if you wish to
190// ignore or prefer particular algorithms). For hosts that have multiple
191// known_hosts entries (of different key types), the result will be sorted by
192// known_hosts filename and line number.
193// If hkdb was originally created by calling NewDB, any @cert-authority lines
194// in the known_hosts file will properly be converted to the corresponding
195// ssh.CertAlgo* values.
196func (hkdb *HostKeyDB) HostKeyAlgorithms(hostWithPort string) (algos []string) {
197 // We ensure that algos never contains duplicates. This is done for robustness
198 // even though currently golang.org/x/crypto/ssh/knownhosts never exposes
199 // multiple keys of the same type. This way our behavior here is unaffected
200 // even if https://github.com/golang/go/issues/28870 is implemented, for
201 // example by https://github.com/golang/crypto/pull/254.
202 hostKeys := hkdb.HostKeys(hostWithPort)
203 seen := make(map[string]struct{}, len(hostKeys))
204 addAlgo := func(typ string, cert bool) {
205 if cert {
206 typ = keyTypeToCertAlgo(typ)
207 }
208 if _, already := seen[typ]; !already {
209 algos = append(algos, typ)
210 seen[typ] = struct{}{}
211 }
212 }
213 for _, key := range hostKeys {
214 typ := key.Type()
215 if typ == ssh.KeyAlgoRSA {
216 // KeyAlgoRSASHA256 and KeyAlgoRSASHA512 are only public key algorithms,
217 // not public key formats, so they can't appear as a PublicKey.Type.
218 // The corresponding PublicKey.Type is KeyAlgoRSA. See RFC 8332, Section 2.
219 addAlgo(ssh.KeyAlgoRSASHA512, key.Cert)
220 addAlgo(ssh.KeyAlgoRSASHA256, key.Cert)
221 }
222 addAlgo(typ, key.Cert)
223 }
224 return algos
225}
226
227func keyTypeToCertAlgo(keyType string) string {
228 switch keyType {
229 case ssh.KeyAlgoRSA:
230 return ssh.CertAlgoRSAv01
231 case ssh.KeyAlgoRSASHA256:
232 return ssh.CertAlgoRSASHA256v01
233 case ssh.KeyAlgoRSASHA512:
234 return ssh.CertAlgoRSASHA512v01
235 case ssh.KeyAlgoDSA:
236 return ssh.CertAlgoDSAv01
237 case ssh.KeyAlgoECDSA256:
238 return ssh.CertAlgoECDSA256v01
239 case ssh.KeyAlgoSKECDSA256:
240 return ssh.CertAlgoSKECDSA256v01
241 case ssh.KeyAlgoECDSA384:
242 return ssh.CertAlgoECDSA384v01
243 case ssh.KeyAlgoECDSA521:
244 return ssh.CertAlgoECDSA521v01
245 case ssh.KeyAlgoED25519:
246 return ssh.CertAlgoED25519v01
247 case ssh.KeyAlgoSKED25519:
248 return ssh.CertAlgoSKED25519v01
249 }
250 return ""
251}
252
253// HostKeyCallback wraps ssh.HostKeyCallback with additional methods to
254// perform host key and algorithm lookups from the known_hosts entries. It is
255// otherwise identical to ssh.HostKeyCallback, and does not introduce any file-
256// parsing behavior beyond what is in golang.org/x/crypto/ssh/knownhosts.
257//
258// In most situations, use HostKeyDB and its constructor NewDB instead of using
259// the HostKeyCallback type. The HostKeyCallback type is only provided for
260// backwards compatibility with older versions of this package, as well as for
261// very strict situations where any extra known_hosts file-parsing is
262// undesirable.
263//
264// Methods of HostKeyCallback do not provide any special treatment for
265// @cert-authority lines, which will (incorrectly) look like normal non-CA host
266// keys. Additionally, HostKeyCallback lacks the fix for applying * wildcard
267// known_host entries to all ports, like OpenSSH's behavior.
268type HostKeyCallback ssh.HostKeyCallback
269
270// New creates a HostKeyCallback from the given OpenSSH known_hosts file(s). The
271// returned value may be used in ssh.ClientConfig.HostKeyCallback by casting it
272// to ssh.HostKeyCallback, or using its HostKeyCallback method. Otherwise, it
273// operates the same as the New function in golang.org/x/crypto/ssh/knownhosts.
274// When supplying multiple files, their order does not matter.
275//
276// In most situations, you should avoid this function, as the returned value
277// lacks several enhanced behaviors. See doc comment for HostKeyCallback for
278// more information. Instead, most callers should use NewDB to create a
279// HostKeyDB, which includes these enhancements.
280func New(files ...string) (HostKeyCallback, error) {
281 cb, err := xknownhosts.New(files...)
282 return HostKeyCallback(cb), err
283}
284
285// HostKeyCallback simply casts the receiver back to ssh.HostKeyCallback, for
286// use in ssh.ClientConfig.HostKeyCallback.
287func (hkcb HostKeyCallback) HostKeyCallback() ssh.HostKeyCallback {
288 return ssh.HostKeyCallback(hkcb)
289}
290
291// ToDB converts the receiver into a HostKeyDB. However, the returned HostKeyDB
292// lacks the enhanced behaviors described in the doc comment for NewDB: proper
293// CA support, and wildcard matching on nonstandard ports.
294//
295// It is generally preferable to create a HostKeyDB by using NewDB. The ToDB
296// method is only provided for situations in which the calling code needs to
297// make the extra NewDB behaviors optional / user-configurable, perhaps for
298// reasons of performance or code trust (since NewDB reads the known_host file
299// an extra time, which may be undesirable in some strict situations). This way,
300// callers can conditionally create a non-enhanced HostKeyDB by using New and
301// ToDB. See code example.
302func (hkcb HostKeyCallback) ToDB() *HostKeyDB {
303 // This intentionally leaves the isCert and isWildcard map fields as nil, as
304 // there is no way to retroactively populate them from just a HostKeyCallback.
305 // Methods of HostKeyDB will skip any related enhanced behaviors accordingly.
306 return &HostKeyDB{callback: ssh.HostKeyCallback(hkcb)}
307}
308
309// HostKeys returns a slice of known host public keys for the supplied host:port
310// found in the known_hosts file(s), or an empty slice if the host is not
311// already known. For hosts that have multiple known_hosts entries (for
312// different key types), the result will be sorted by known_hosts filename and
313// line number.
314// In the returned values, there is no way to distinguish between CA keys
315// (known_hosts lines beginning with @cert-authority) and regular keys. To do
316// so, see NewDB and HostKeyDB.HostKeys instead.
317func (hkcb HostKeyCallback) HostKeys(hostWithPort string) []ssh.PublicKey {
318 annotatedKeys := hkcb.ToDB().HostKeys(hostWithPort)
319 rawKeys := make([]ssh.PublicKey, len(annotatedKeys))
320 for n, ak := range annotatedKeys {
321 rawKeys[n] = ak.PublicKey
322 }
323 return rawKeys
324}
325
326// HostKeyAlgorithms returns a slice of host key algorithms for the supplied
327// host:port found in the known_hosts file(s), or an empty slice if the host
328// is not already known. The result may be used in ssh.ClientConfig's
329// HostKeyAlgorithms field, either as-is or after filtering (if you wish to
330// ignore or prefer particular algorithms). For hosts that have multiple
331// known_hosts entries (for different key types), the result will be sorted by
332// known_hosts filename and line number.
333// The returned values will not include ssh.CertAlgo* values. If any
334// known_hosts lines had @cert-authority prefixes, their original key algo will
335// be returned instead. For proper CA support, see NewDB and
336// HostKeyDB.HostKeyAlgorithms instead.
337func (hkcb HostKeyCallback) HostKeyAlgorithms(hostWithPort string) (algos []string) {
338 return hkcb.ToDB().HostKeyAlgorithms(hostWithPort)
339}
340
341// HostKeyAlgorithms is a convenience function for performing host key algorithm
342// lookups on an ssh.HostKeyCallback directly. It is intended for use in code
343// paths that stay with the New method of golang.org/x/crypto/ssh/knownhosts
344// rather than this package's New or NewDB methods.
345// The returned values will not include ssh.CertAlgo* values. If any
346// known_hosts lines had @cert-authority prefixes, their original key algo will
347// be returned instead. For proper CA support, see NewDB and
348// HostKeyDB.HostKeyAlgorithms instead.
349func HostKeyAlgorithms(cb ssh.HostKeyCallback, hostWithPort string) []string {
350 return HostKeyCallback(cb).HostKeyAlgorithms(hostWithPort)
351}
352
353// IsHostKeyChanged returns a boolean indicating whether the error indicates
354// the host key has changed. It is intended to be called on the error returned
355// from invoking a host key callback, to check whether an SSH host is known.
356func IsHostKeyChanged(err error) bool {
357 var keyErr *xknownhosts.KeyError
358 return errors.As(err, &keyErr) && len(keyErr.Want) > 0
359}
360
361// IsHostUnknown returns a boolean indicating whether the error represents an
362// unknown host. It is intended to be called on the error returned from invoking
363// a host key callback to check whether an SSH host is known.
364func IsHostUnknown(err error) bool {
365 var keyErr *xknownhosts.KeyError
366 return errors.As(err, &keyErr) && len(keyErr.Want) == 0
367}
368
369// Normalize normalizes an address into the form used in known_hosts. This
370// implementation includes a fix for https://github.com/golang/go/issues/53463
371// and will omit brackets around ipv6 addresses on standard port 22.
372func Normalize(address string) string {
373 host, port, err := net.SplitHostPort(address)
374 if err != nil {
375 host = address
376 port = "22"
377 }
378 entry := host
379 if port != "22" {
380 entry = "[" + entry + "]:" + port
381 } else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
382 entry = entry[1 : len(entry)-1]
383 }
384 return entry
385}
386
387// Line returns a line to append to the known_hosts files. This implementation
388// uses the local patched implementation of Normalize in order to solve
389// https://github.com/golang/go/issues/53463.
390func Line(addresses []string, key ssh.PublicKey) string {
391 var trimmed []string
392 for _, a := range addresses {
393 trimmed = append(trimmed, Normalize(a))
394 }
395
396 return strings.Join([]string{
397 strings.Join(trimmed, ","),
398 key.Type(),
399 base64.StdEncoding.EncodeToString(key.Marshal()),
400 }, " ")
401}
402
403// WriteKnownHost writes a known_hosts line to w for the supplied hostname,
404// remote, and key. This is useful when writing a custom hostkey callback which
405// wraps a callback obtained from this package to provide additional known_hosts
406// management functionality. The hostname, remote, and key typically correspond
407// to the callback's args. This function does not support writing
408// @cert-authority lines.
409func WriteKnownHost(w io.Writer, hostname string, remote net.Addr, key ssh.PublicKey) error {
410 // Always include hostname; only also include remote if it isn't a zero value
411 // and doesn't normalize to the same string as hostname.
412 hostnameNormalized := Normalize(hostname)
413 if strings.ContainsAny(hostnameNormalized, "\t ") {
414 return fmt.Errorf("knownhosts: hostname '%s' contains spaces", hostnameNormalized)
415 }
416 addresses := []string{hostnameNormalized}
417 remoteStrNormalized := Normalize(remote.String())
418 if remoteStrNormalized != "[0.0.0.0]:0" && remoteStrNormalized != hostnameNormalized &&
419 !strings.ContainsAny(remoteStrNormalized, "\t ") {
420 addresses = append(addresses, remoteStrNormalized)
421 }
422 line := Line(addresses, key) + "\n"
423 _, err := w.Write([]byte(line))
424 return err
425}
426
427// WriteKnownHostCA writes a @cert-authority line to w for the supplied host
428// name/pattern and key.
429func WriteKnownHostCA(w io.Writer, hostPattern string, key ssh.PublicKey) error {
430 encodedKey := base64.StdEncoding.EncodeToString(key.Marshal())
431 _, err := fmt.Fprintf(w, "@cert-authority %s %s %s\n", hostPattern, key.Type(), encodedKey)
432 return err
433}
434
435// fakePublicKey is used as part of the work-around for
436// https://github.com/golang/go/issues/29286
437type fakePublicKey struct{}
438
439func (fakePublicKey) Type() string {
440 return "fake-public-key"
441}
442func (fakePublicKey) Marshal() []byte {
443 return []byte("fake public key")
444}
445func (fakePublicKey) Verify(_ []byte, _ *ssh.Signature) error {
446 return errors.New("Verify called on placeholder key")
447}