main
Raw Download raw file
  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}