From 041ab54215d7ec0931a019b60e2f3580cfd80cca Mon Sep 17 00:00:00 2001 From: Evan Elias Date: Sat, 6 Jul 2024 19:48:12 -0400 Subject: [PATCH] wip: reimplement previous commit in a way that maintains backwards compat !!! This is a work-in-progress commit, which will be rewritten/amended !!! This still needs one or two more unit tests --- README.md | 11 +- example_test.go | 26 ++++- knownhosts.go | 251 ++++++++++++++++++++++++++++++++------------- knownhosts_test.go | 202 +++++++++++++++++++++++++++++++++++- 4 files changed, 408 insertions(+), 82 deletions(-) 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..0df27cc 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) { diff --git a/knownhosts.go b/knownhosts.go index 7835726..5a5bb1a 100644 --- a/knownhosts.go +++ b/knownhosts.go @@ -3,12 +3,14 @@ package knownhosts import ( + "bufio" + "bytes" "encoding/base64" "errors" "fmt" "io" - "io/ioutil" "net" + "os" "sort" "strings" @@ -16,28 +18,68 @@ 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 the +// known_hosts entries, including the abililty to treat CAs differently than +// other host keys. To create a HostKeyDB, use NewDB. +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 differently +// than other entries. +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 for use in +// ssh.ClientConfig.HostKeyCallback. +func (hkdb *HostKeyDB) HostKeyCallback() ssh.HostKeyCallback { + return hkdb.callback } +// PublicKey wraps ssh.PublicKey with an additional field, to identify +// whether they key is a certificate authority. type PublicKey struct { ssh.PublicKey - cert bool + Cert bool } // HostKeys returns a slice of known host public keys for the supplied host:port @@ -45,12 +87,12 @@ type PublicKey struct { // 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 []PublicKey) { +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 { @@ -60,28 +102,66 @@ func (hkcb HostKeyCallback) HostKeys(hostWithPort string) (keys []PublicKey) { } sort.Slice(kkeys, knownKeyLess) keys = make([]PublicKey, len(kkeys)) - for n, k := range kkeys { - content, err := ioutil.ReadFile(k.Filename) - if err != nil { - continue - } - lines := strings.Split(string(content), "\n") - line := lines[k.Line-1] - isCert := strings.HasPrefix(line, "@cert-authority") - + for n := range kkeys { keys[n] = PublicKey{ - PublicKey: k.Key, - cert: isCert, + 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 (for different key types), the result will be sorted by +// known_hosts filename and line number. +// For @cert-authority lines, the returned algorithm will be the correct +// ssh.CertAlgo* value. +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 = keyTypeToCertType(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 keyTypeToCertType(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: @@ -100,6 +180,59 @@ func keyTypeToCertType(keyType string) string { return "" } +// HostKeyCallback wraps ssh.HostKeyCallback with an additional method to +// perform host key 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. +// +// Note that its HostKeys and HostKeyAlgorithms methods do not provide any +// special treatment for @cert-authority lines, which will look like normal +// non-CA host keys. For proper CA support, e.g. when building a general-purpose +// SSH client, use HostKeyDB instead. +// +// HostKeyCallback should generally only be used in situations in which +// @cert-authority lines are unlikely (for example, Git-related use-cases, since +// Git forges generally don't use them), or in situations where the extra file- +// parsing is undesirable, for reasons of code trust / security or perhaps +// performance impact. +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. +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) +} + +// 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, +// use HostKeyDB.HostKeys instead. +func (hkcb HostKeyCallback) HostKeys(hostWithPort string) []ssh.PublicKey { + // Approach: create a HostKeyDB without an isCert map; call its HostKeys + // method (which will skip the cert-related logic due to isCert map being + // nil); pull out the ssh.PublicKey from each result + hkdb := HostKeyDB{callback: ssh.HostKeyCallback(hkcb)} + annotatedKeys := hkdb.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 @@ -107,58 +240,31 @@ func keyTypeToCertType(keyType string) string { // 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, even if the +// known_hosts lines had @cert-authority prefixes. For CA support, use +// HostKeyDB.HostKeyAlgorithms instead. func (hkcb HostKeyCallback) 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) - seen := make(map[string]struct{}, len(hostKeys)) - addAlgo := func(typ string) { - if _, already := seen[typ]; !already { - algos = append(algos, typ) - seen[typ] = struct{}{} - } - } - for _, key := range hostKeys { - typ := key.Type() - if key.cert { - certType := keyTypeToCertType(typ) - if certType == ssh.CertAlgoRSAv01 { - - // CertAlgoRSASHA256v01 and CertAlgoRSASHA512v01 can't appear as a - // Certificate.Type (or PublicKey.Type), but only in - // ClientConfig.HostKeyAlgorithms. - addAlgo(ssh.CertAlgoRSASHA256v01) - addAlgo(ssh.CertAlgoRSASHA512v01) - } - addAlgo(certType) - } else { - 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) - addAlgo(ssh.KeyAlgoRSASHA256) - } - addAlgo(typ) - } - } - return algos + // Approach: create a HostKeyDB without an isCert map; call its + // HostKeyAlgorithms method (which will skip the cert-related logic due to + // isCert map being nil); the result is suitable for returning as-is + hkdb := HostKeyDB{callback: ssh.HostKeyCallback(hkcb)} + return hkdb.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, even if the +// known_hosts lines had @cert-authority prefixes. For CA support, use +// 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 @@ -166,7 +272,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 @@ -208,9 +314,10 @@ func Line(addresses []string, key ssh.PublicKey) string { // WriteKnownHost writes a known_hosts line to writer 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. diff --git a/knownhosts_test.go b/knownhosts_test.go index 8ace01b..847dc27 100644 --- a/knownhosts_test.go +++ b/knownhosts_test.go @@ -7,14 +7,49 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/rsa" + "encoding/base64" + "fmt" "net" "os" "path/filepath" + "strings" "testing" "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) + } + + // 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) @@ -28,10 +63,45 @@ func TestNew(t *testing.T) { } } - // 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) { @@ -66,6 +136,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) @@ -263,7 +418,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 +434,49 @@ 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 + } + + if strings.TrimSpace(hostPattern) == "" { + hostPattern = "*" + } + + 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() + encodedKey := base64.StdEncoding.EncodeToString(pubKey.Marshal()) + if _, err = fmt.Fprintf(f, "@cert-authority %s %s %s\n", hostPattern, pubKey.Type(), encodedKey); 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)