diff --git a/README.md b/README.md index e24ee02..d569123 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ This repo ([github.com/skeema/knownhosts](https://github.com/skeema/knownhosts)) Although [golang.org/x/crypto/ssh/knownhosts](https://pkg.go.dev/golang.org/x/crypto/ssh/knownhosts) doesn't directly expose a way to query its known_host map, we use a subtle trick to do so: invoke the HostKeyCallback with a valid host but a bogus key. The resulting KeyError allows us to determine which public keys are actually present for that host. -By using this technique, [github.com/skeema/knownhosts](https://github.com/skeema/knownhosts) doesn't need to duplicate or re-implement any of the actual known_hosts management from [golang.org/x/crypto/ssh/knownhosts](https://pkg.go.dev/golang.org/x/crypto/ssh/knownhosts). +By using this technique, [github.com/skeema/knownhosts](https://github.com/skeema/knownhosts) doesn't need to duplicate any of the core known_hosts host-lookup logic from [golang.org/x/crypto/ssh/knownhosts](https://pkg.go.dev/golang.org/x/crypto/ssh/knownhosts). ## Populating ssh.ClientConfig.HostKeyAlgorithms based on known_hosts @@ -43,14 +43,14 @@ import ( ) func sshConfigForHost(hostWithPort string) (*ssh.ClientConfig, error) { - kh, err := knownhosts.New("/home/myuser/.ssh/known_hosts") + kh, err := knownhosts.NewDB("/home/myuser/.ssh/known_hosts") if err != nil { return nil, err } config := &ssh.ClientConfig{ User: "myuser", Auth: []ssh.AuthMethod{ /* ... */ }, - HostKeyCallback: kh.HostKeyCallback(), // or, equivalently, use ssh.HostKeyCallback(kh) + HostKeyCallback: kh.HostKeyCallback(), HostKeyAlgorithms: kh.HostKeyAlgorithms(hostWithPort), } return config, nil @@ -64,7 +64,7 @@ If you wish to mimic the behavior of OpenSSH's `StrictHostKeyChecking=no` or `St ```golang sshHost := "yourserver.com:22" khPath := "/home/myuser/.ssh/known_hosts" -kh, err := knownhosts.New(khPath) +kh, err := knownhosts.NewDB(khPath) if err != nil { log.Fatal("Failed to read known_hosts: ", err) } @@ -72,7 +72,8 @@ if err != nil { // Create a custom permissive hostkey callback which still errors on hosts // with changed keys, but allows unknown hosts and adds them to known_hosts cb := ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error { - err := kh(hostname, remote, key) + innerCallback := kh.HostKeyCallback() + err := innerCallback(hostname, remote, key) if knownhosts.IsHostKeyChanged(err) { return fmt.Errorf("REMOTE HOST IDENTIFICATION HAS CHANGED for host %s! This may indicate a MitM attack.", hostname) } else if knownhosts.IsHostUnknown(err) { diff --git a/example_test.go b/example_test.go index 17ae3e2..7a502d9 100644 --- a/example_test.go +++ b/example_test.go @@ -19,7 +19,26 @@ func ExampleNew() { config := &ssh.ClientConfig{ User: "myuser", Auth: []ssh.AuthMethod{ /* ... */ }, - HostKeyCallback: kh.HostKeyCallback(), // or, equivalently, use ssh.HostKeyCallback(kh) + HostKeyCallback: kh.HostKeyCallback(), + HostKeyAlgorithms: kh.HostKeyAlgorithms(sshHost), + } + client, err := ssh.Dial("tcp", sshHost, config) + if err != nil { + log.Fatal("Failed to dial: ", err) + } + defer client.Close() +} + +func ExampleNewDB() { + sshHost := "yourserver.com:22" + kh, err := knownhosts.NewDB("/home/myuser/.ssh/known_hosts") + if err != nil { + log.Fatal("Failed to read known_hosts: ", err) + } + config := &ssh.ClientConfig{ + User: "myuser", + Auth: []ssh.AuthMethod{ /* ... */ }, + HostKeyCallback: kh.HostKeyCallback(), HostKeyAlgorithms: kh.HostKeyAlgorithms(sshHost), } client, err := ssh.Dial("tcp", sshHost, config) @@ -32,7 +51,7 @@ func ExampleNew() { func ExampleWriteKnownHost() { sshHost := "yourserver.com:22" khPath := "/home/myuser/.ssh/known_hosts" - kh, err := knownhosts.New(khPath) + kh, err := knownhosts.NewDB(khPath) if err != nil { log.Fatal("Failed to read known_hosts: ", err) } @@ -40,7 +59,8 @@ func ExampleWriteKnownHost() { // Create a custom permissive hostkey callback which still errors on hosts // with changed keys, but allows unknown hosts and adds them to known_hosts cb := ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error { - err := kh(hostname, remote, key) + innerCallback := kh.HostKeyCallback() + err := innerCallback(hostname, remote, key) if knownhosts.IsHostKeyChanged(err) { return fmt.Errorf("REMOTE HOST IDENTIFICATION HAS CHANGED for host %s! This may indicate a MitM attack.", hostname) } else if knownhosts.IsHostUnknown(err) { @@ -70,5 +90,4 @@ func ExampleWriteKnownHost() { log.Fatal("Failed to dial: ", err) } defer client.Close() - } diff --git a/knownhosts.go b/knownhosts.go index 4dad777..f1ff837 100644 --- a/knownhosts.go +++ b/knownhosts.go @@ -3,11 +3,14 @@ package knownhosts import ( + "bufio" + "bytes" "encoding/base64" "errors" "fmt" "io" "net" + "os" "sort" "strings" @@ -15,23 +18,69 @@ import ( xknownhosts "golang.org/x/crypto/ssh/knownhosts" ) -// HostKeyCallback wraps ssh.HostKeyCallback with an additional method to -// perform host key algorithm lookups from the known_hosts entries. -type HostKeyCallback ssh.HostKeyCallback +// 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" +} -// New creates a host key callback from the given OpenSSH host key files. 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. -func New(files ...string) (HostKeyCallback, error) { +// 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. +// When supplying multiple files, their order does not matter. +func NewDB(files ...string) (*HostKeyDB, error) { cb, err := xknownhosts.New(files...) - return HostKeyCallback(cb), err + if err != nil { + return nil, err + } + hkdb := &HostKeyDB{ + callback: cb, + isCert: 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 + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("knownhosts: %s:%d: %w", filename, lineNum, err) + } + } + return hkdb, nil } -// 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) +// 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 { + return hkdb.callback +} + +// PublicKey wraps ssh.PublicKey with an additional field, to identify +// whether they 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 @@ -39,12 +88,15 @@ func (hkcb HostKeyCallback) HostKeyCallback() ssh.HostKeyCallback { // 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. -func (hkcb HostKeyCallback) HostKeys(hostWithPort string) (keys []ssh.PublicKey) { +// 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 - if hkcbErr := hkcb(hostWithPort, placeholderAddr, placeholderPubKey); errors.As(hkcbErr, &keyErr) { + if hkcbErr := hkdb.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 { @@ -53,9 +105,14 @@ func (hkcb HostKeyCallback) HostKeys(hostWithPort string) (keys []ssh.PublicKey) return (kkeys[i].Filename == kkeys[j].Filename && kkeys[i].Line < kkeys[j].Line) } sort.Slice(kkeys, knownKeyLess) - keys = make([]ssh.PublicKey, len(kkeys)) + keys = make([]PublicKey, len(kkeys)) for n := range kkeys { - keys[n] = kkeys[n].Key + 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 @@ -66,17 +123,23 @@ func (hkcb HostKeyCallback) HostKeys(hostWithPort string) (keys []ssh.PublicKey) // 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 entries (of different key types), the result will be sorted by // known_hosts filename and line number. -func (hkcb HostKeyCallback) HostKeyAlgorithms(hostWithPort string) (algos []string) { +// 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 := hkcb.HostKeys(hostWithPort) + hostKeys := hkdb.HostKeys(hostWithPort) seen := make(map[string]struct{}, len(hostKeys)) - addAlgo := func(typ string) { + addAlgo := func(typ string, cert bool) { + if cert { + typ = keyTypeToCertAlgo(typ) + } if _, already := seen[typ]; !already { algos = append(algos, typ) seen[typ] = struct{}{} @@ -88,25 +151,137 @@ func (hkcb HostKeyCallback) HostKeyAlgorithms(hostWithPort string) (algos []stri // 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) - addAlgo(ssh.KeyAlgoRSASHA256) + addAlgo(ssh.KeyAlgoRSASHA512, key.Cert) + addAlgo(ssh.KeyAlgoRSASHA256, key.Cert) } - addAlgo(typ) + 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. +// +// Methods of HostKeyCallback do not provide any special treatment for +// @cert-authority lines, which will (incorrectly) look like normal non-CA host +// keys. HostKeyCallback should generally only be used in situations in which +// @cert-authority lines won't appear, and/or in very strict situations where +// any extra known_hosts file-parsing is undesirable. +// +// In most situations, use HostKeyDB and its constructor NewDB instead of using +// the HostKeyCallback type. +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 +// does not handle @cert-authority lines correctly. See doc comment for +// HostKeyCallback for more information. Instead, use NewDB to create a +// HostKeyDB with proper CA support. +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 proper CA support. It is usually preferable to create a CA-supporting +// HostKeyDB instead, by using NewDB. +// This method is provided for situations in which the calling code needs to +// make CA support optional / user-configurable. This way, calling code can +// conditionally create a non-CA-supporting HostKeyDB by calling New(...).ToDB() +// or a CA-supporting HostKeyDB by calling NewDB(...). +func (hkcb HostKeyCallback) ToDB() *HostKeyDB { + // This intentionally leaves the isCert map field as nil, as there is no way + // to retroactively populate it from just a HostKeyCallback. Methods of + // HostKeyDB will skip any CA-related 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 method. +// 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 HostKeyCallback to check whether an SSH host is known. +// 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 @@ -114,7 +289,7 @@ func IsHostKeyChanged(err error) bool { // 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 HostKeyCallback to check whether an SSH host is known. +// 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 @@ -154,11 +329,12 @@ func Line(addresses []string, key ssh.PublicKey) string { }, " ") } -// WriteKnownHost writes a known_hosts line to writer for the supplied hostname, +// 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 knownhosts.New to provide additional -// known_hosts management functionality. The hostname, remote, and key typically -// correspond to the callback's args. +// 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. @@ -177,6 +353,14 @@ func WriteKnownHost(w io.Writer, hostname string, remote net.Addr, key ssh.Publi 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{} diff --git a/knownhosts_test.go b/knownhosts_test.go index 8ace01b..473fc1a 100644 --- a/knownhosts_test.go +++ b/knownhosts_test.go @@ -15,6 +15,43 @@ import ( "golang.org/x/crypto/ssh" ) +func TestNewDB(t *testing.T) { + khPath := getTestKnownHosts(t) + + // Valid path should return a non-nil HostKeyDB and no error + if kh, err := NewDB(khPath); kh == nil || err != nil { + t.Errorf("Unexpected return from NewDB on valid known_hosts path: %v, %v", kh, err) + } else { + // Confirm return value of HostKeyCallback is an ssh.HostKeyCallback + _ = ssh.ClientConfig{ + HostKeyCallback: kh.HostKeyCallback(), + } + } + + // Append a @cert-authority line to the valid known_hosts file + // Valid path should still return a non-nil HostKeyDB and no error + appendCertTestKnownHosts(t, khPath, "*", ssh.KeyAlgoECDSA256) + if kh, err := NewDB(khPath); kh == nil || err != nil { + t.Errorf("Unexpected return from NewDB on valid known_hosts path containing a cert: %v, %v", kh, err) + } + + // Write a second valid known_hosts file + // Supplying both valid paths should still return a non-nil HostKeyDB and no + // error + appendCertTestKnownHosts(t, khPath+"2", "*.certy.test", ssh.KeyAlgoED25519) + if kh, err := NewDB(khPath+"2", khPath); kh == nil || err != nil { + t.Errorf("Unexpected return from NewDB on two valid known_hosts paths: %v, %v", kh, err) + } + + // Invalid path should return an error, with or without other valid paths + if _, err := NewDB(khPath + "_does_not_exist"); err == nil { + t.Error("Expected error from NewDB with invalid path, but error was nil") + } + if _, err := NewDB(khPath, khPath+"_does_not_exist"); err == nil { + t.Error("Expected error from NewDB with mix of valid and invalid paths, but error was nil") + } +} + func TestNew(t *testing.T) { khPath := getTestKnownHosts(t) @@ -23,15 +60,55 @@ func TestNew(t *testing.T) { if kh, err := New(khPath); err != nil { t.Errorf("Unexpected error from New on valid known_hosts path: %v", err) } else { + // Confirm kh can be converted to an ssh.HostKeyCallback + _ = ssh.ClientConfig{ + HostKeyCallback: ssh.HostKeyCallback(kh), + } + // Confirm return value of HostKeyCallback is an ssh.HostKeyCallback _ = ssh.ClientConfig{ HostKeyCallback: kh.HostKeyCallback(), } } - // Invalid path should return an error + // Invalid path should return an error, with or without other valid paths if _, err := New(khPath + "_does_not_exist"); err == nil { t.Error("Expected error from New with invalid path, but error was nil") } + if _, err := New(khPath, khPath+"_does_not_exist"); err == nil { + t.Error("Expected error from New with mix of valid and invalid paths, but error was nil") + } +} + +func TestHostKeys(t *testing.T) { + khPath := getTestKnownHosts(t) + kh, err := New(khPath) + if err != nil { + t.Fatalf("Unexpected error from New: %v", err) + } + + expectedKeyTypes := map[string][]string{ + "only-rsa.example.test:22": {"ssh-rsa"}, + "only-ecdsa.example.test:22": {"ecdsa-sha2-nistp256"}, + "only-ed25519.example.test:22": {"ssh-ed25519"}, + "multi.example.test:2233": {"ssh-rsa", "ecdsa-sha2-nistp256", "ssh-ed25519"}, + "192.168.1.102:2222": {"ecdsa-sha2-nistp256", "ssh-ed25519"}, + "unknown-host.example.test": {}, // host not in file + "multi.example.test:22": {}, // different port than entry in file + "192.168.1.102": {}, // different port than entry in file + } + for host, expected := range expectedKeyTypes { + actual := kh.HostKeys(host) + if len(actual) != len(expected) { + t.Errorf("Unexpected number of keys returned by HostKeys(%q): expected %d, found %d", host, len(expected), len(actual)) + continue + } + for n := range expected { + if actualType := actual[n].Type(); expected[n] != actualType { + t.Errorf("Unexpected key returned by HostKeys(%q): expected key[%d] to be type %v, found %v", host, n, expected, actualType) + break + } + } + } } func TestHostKeyAlgorithms(t *testing.T) { @@ -53,12 +130,13 @@ func TestHostKeyAlgorithms(t *testing.T) { } for host, expected := range expectedAlgorithms { actual := kh.HostKeyAlgorithms(host) - if len(actual) != len(expected) { + actual2 := HostKeyAlgorithms(kh.HostKeyCallback(), host) + if len(actual) != len(expected) || len(actual2) != len(expected) { t.Errorf("Unexpected number of algorithms returned by HostKeyAlgorithms(%q): expected %d, found %d", host, len(expected), len(actual)) continue } for n := range expected { - if expected[n] != actual[n] { + if expected[n] != actual[n] || expected[n] != actual2[n] { t.Errorf("Unexpected algorithms returned by HostKeyAlgorithms(%q): expected %v, found %v", host, expected, actual) break } @@ -66,6 +144,91 @@ func TestHostKeyAlgorithms(t *testing.T) { } } +func TestWithCertLines(t *testing.T) { + khPath := getTestKnownHosts(t) + khPath2 := khPath + "2" + appendCertTestKnownHosts(t, khPath, "*.certy.test", ssh.KeyAlgoRSA) + appendCertTestKnownHosts(t, khPath2, "*", ssh.KeyAlgoECDSA256) + appendCertTestKnownHosts(t, khPath2, "*.certy.test", ssh.KeyAlgoED25519) + + // Test behavior of HostKeyCallback type, which doesn't properly handle + // @cert-authority lines but shouldn't error on them. It should just return + // them as regular keys / algorithms. + cbOnly, err := New(khPath2, khPath) + if err != nil { + t.Fatalf("Unexpected error from New: %v", err) + } + algos := cbOnly.HostKeyAlgorithms("only-ed25519.example.test:22") + // algos should return ssh.KeyAlgoED25519 (as per previous test) but now also + // ssh.KeyAlgoECDSA256 due to the cert entry on *. They should always be in + // that order due to matching the file and line order from NewDB. + if len(algos) != 2 || algos[0] != ssh.KeyAlgoED25519 || algos[1] != ssh.KeyAlgoECDSA256 { + t.Errorf("Unexpected return from HostKeyCallback.HostKeyAlgorithms: %v", algos) + } + + // Now test behavior of HostKeyDB type, which should properly support + // @cert-authority lines as being different from other lines + kh, err := NewDB(khPath2, khPath) + if err != nil { + t.Fatalf("Unexpected error from NewDB: %v", err) + } + testCases := []struct { + host string + expectedKeyTypes []string + expectedIsCert []bool + expectedAlgos []string + }{ + { + host: "only-ed25519.example.test:22", + expectedKeyTypes: []string{ssh.KeyAlgoED25519, ssh.KeyAlgoECDSA256}, + expectedIsCert: []bool{false, true}, + expectedAlgos: []string{ssh.KeyAlgoED25519, ssh.CertAlgoECDSA256v01}, + }, + { + host: "only-rsa.example.test:22", + expectedKeyTypes: []string{ssh.KeyAlgoRSA, ssh.KeyAlgoECDSA256}, + expectedIsCert: []bool{false, true}, + expectedAlgos: []string{ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256, ssh.KeyAlgoRSA, ssh.CertAlgoECDSA256v01}, + }, + { + host: "whatever.lol.test:22", // only matches the * entry + expectedKeyTypes: []string{ssh.KeyAlgoECDSA256}, + expectedIsCert: []bool{true}, + expectedAlgos: []string{ssh.CertAlgoECDSA256v01}, + }, + { + host: "asdf.certy.test:22", + expectedKeyTypes: []string{ssh.KeyAlgoRSA, ssh.KeyAlgoECDSA256, ssh.KeyAlgoED25519}, + expectedIsCert: []bool{true, true, true}, + expectedAlgos: []string{ssh.CertAlgoRSASHA512v01, ssh.CertAlgoRSASHA256v01, ssh.CertAlgoRSAv01, ssh.CertAlgoECDSA256v01, ssh.CertAlgoED25519v01}, + }, + } + for _, tc := range testCases { + annotatedKeys := kh.HostKeys(tc.host) + if len(annotatedKeys) != len(tc.expectedKeyTypes) { + t.Errorf("Unexpected return from HostKeys(%q): %v", tc.host, annotatedKeys) + } else { + for n := range annotatedKeys { + if annotatedKeys[n].Type() != tc.expectedKeyTypes[n] || annotatedKeys[n].Cert != tc.expectedIsCert[n] { + t.Errorf("Unexpected return from HostKeys(%q) at index %d: %v", tc.host, n, annotatedKeys) + break + } + } + } + algos := kh.HostKeyAlgorithms(tc.host) + if len(algos) != len(tc.expectedAlgos) { + t.Errorf("Unexpected return from HostKeyAlgorithms(%q): %v", tc.host, algos) + } else { + for n := range algos { + if algos[n] != tc.expectedAlgos[n] { + t.Errorf("Unexpected return from HostKeyAlgorithms(%q) at index %d: %v", tc.host, n, algos) + break + } + } + } + } +} + func TestIsHostKeyChanged(t *testing.T) { khPath := getTestKnownHosts(t) kh, err := New(khPath) @@ -222,6 +385,16 @@ func TestWriteKnownHost(t *testing.T) { } } +func TestFakePublicKey(t *testing.T) { + fpk := fakePublicKey{} + if err := fpk.Verify(nil, nil); err == nil { + t.Error("Expected fakePublicKey.Verify() to always return an error, but it did not") + } + if certAlgo := keyTypeToCertAlgo(fpk.Type()); certAlgo != "" { + t.Errorf("Expected keyTypeToCertAlgo on a fakePublicKey to return an empty string, but instead found %q", certAlgo) + } +} + var testKnownHostsContents []byte // getTestKnownHosts returns a path to a test known_hosts file. The file path @@ -263,7 +436,7 @@ func writeTestKnownHosts(t *testing.T) string { dir := t.TempDir() khPath := filepath.Join(dir, "known_hosts") - f, err := os.OpenFile(khPath, os.O_CREATE|os.O_WRONLY, 0600) + f, err := os.OpenFile(khPath, os.O_WRONLY|os.O_CREATE, 0600) if err != nil { t.Fatalf("Unable to open %s for writing: %v", khPath, err) } @@ -279,6 +452,44 @@ func writeTestKnownHosts(t *testing.T) string { return khPath } +var testCertKeys = make(map[string]ssh.PublicKey) // key string format is "hostpattern keytype" + +// appendCertTestKnownHosts adds a @cert-authority line to the file at the +// supplied path, creating it if it does not exist yet. The keyType must be one +// of ssh.KeyAlgoRSA, ssh.KeyAlgoECDSA256, or ssh.KeyAlgoED25519; while all +// valid algos are supported by this package, the test logic hasn't been +// written for other algos here yet. Generated keys are memoized to avoid +// slow test performance. +func appendCertTestKnownHosts(t *testing.T, filePath, hostPattern, keyType string) { + t.Helper() + + var pubKey ssh.PublicKey + var ok bool + cacheKey := hostPattern + " " + keyType + if pubKey, ok = testCertKeys[cacheKey]; !ok { + switch keyType { + case ssh.KeyAlgoRSA: + pubKey = generatePubKeyRSA(t) + case ssh.KeyAlgoECDSA256: + pubKey = generatePubKeyECDSA(t) + case ssh.KeyAlgoED25519: + pubKey = generatePubKeyEd25519(t) + default: + t.Fatalf("test logic does not support generating key of type %s yet", keyType) + } + testCertKeys[cacheKey] = pubKey + } + + f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600) + if err != nil { + t.Fatalf("Unable to open %s for writing: %v", filePath, err) + } + defer f.Close() + if err := WriteKnownHostCA(f, hostPattern, pubKey); err != nil { + t.Fatalf("Unable to append @cert-authority line to %s: %v", filePath, err) + } +} + func generatePubKeyRSA(t *testing.T) ssh.PublicKey { t.Helper() privKey, err := rsa.GenerateKey(rand.Reader, 4096)