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