From d314bf36fde12e8064efdd5de9779beba65cb644 Mon Sep 17 00:00:00 2001 From: Javier Alvarez Garcia Date: Mon, 17 Jun 2024 15:20:11 +0200 Subject: [PATCH 1/3] Support cert authorities --- knownhosts.go | 74 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/knownhosts.go b/knownhosts.go index 4dad777..7835726 100644 --- a/knownhosts.go +++ b/knownhosts.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "io/ioutil" "net" "sort" "strings" @@ -34,12 +35,17 @@ func (hkcb HostKeyCallback) HostKeyCallback() ssh.HostKeyCallback { return ssh.HostKeyCallback(hkcb) } +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. -func (hkcb HostKeyCallback) HostKeys(hostWithPort string) (keys []ssh.PublicKey) { +func (hkcb HostKeyCallback) HostKeys(hostWithPort string) (keys []PublicKey) { var keyErr *xknownhosts.KeyError placeholderAddr := &net.TCPAddr{IP: []byte{0, 0, 0, 0}} placeholderPubKey := &fakePublicKey{} @@ -53,14 +59,47 @@ 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)) - for n := range kkeys { - keys[n] = kkeys[n].Key + 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") + + keys[n] = PublicKey{ + PublicKey: k.Key, + cert: isCert, + } } } return keys } +func keyTypeToCertType(keyType string) string { + switch keyType { + case ssh.KeyAlgoRSA: + return ssh.CertAlgoRSAv01 + 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 "" +} + // 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 @@ -84,14 +123,27 @@ func (hkcb HostKeyCallback) HostKeyAlgorithms(hostWithPort string) (algos []stri } 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) - addAlgo(ssh.KeyAlgoRSASHA256) + 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) } - addAlgo(typ) } return algos } From 69b4a6244d5cd06d0ad414d4cef40edeead3c115 Mon Sep 17 00:00:00 2001 From: Evan Elias Date: Sun, 7 Jul 2024 18:46:26 -0400 Subject: [PATCH 2/3] certs: reimplement previous commit to maintain backwards compat The previous commit d314bf36 added support for @cert-authority lines, but technically broke backwards compatibility due to changing the return type of one exported method. This commit adjusts that previous commit's new logic to restore backwards compatibility, and makes additional changes as follows: * Introduce new exported type HostKeyDB, which handles @cert-authority lines correctly and is returned by NewDB; old exported type HostKeyCallback (which is returned by New) omits that handling. Git-specific use-cases can likely remain with using New, since Git forges typically don't support CAs. Non-Git use-cases, such as general-purpose SSH clients, should consider switching to NewDB to get the CA logic. * When NewDB re-reads the known_hosts files to implement the CA support, it only re-reads each file a single time (vs potentially multiple times at callback execution time in d314bf36), and it reads using buffered IO similar to x/crypto/ssh/knownhosts. * This package's PublicKey struct now exports its Cert boolean field, vs keeping it private in d314bf36. * Refactor the RSA-to-algo expansion logic to simplify its handling in the CA situation. * Add test coverage for all new behaviors and @cert-authority logic. --- README.md | 11 +- example_test.go | 26 ++++- knownhosts.go | 255 ++++++++++++++++++++++++++++++++------------- knownhosts_test.go | 227 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 434 insertions(+), 85 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..1aeeb60 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,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 the +// known_hosts entries. It fully supports @cert-authority lines as well, and can +// return ssh.CertAlgo* values when looking up algorithms. 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 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 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 corresponds to 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 +88,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 +103,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 } -func keyTypeToCertType(keyType string) string { +// 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. +// 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 = 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: @@ -100,6 +181,60 @@ 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. +// When supplying multiple files, their order does not matter. +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 +242,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. If any +// known_hosts lines had @cert-authority prefixes, their original key algo will +// be returned instead. For proper CA support, use HostKeyDB.HostKeyAlgorithms. 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. If any +// known_hosts lines had @cert-authority prefixes, their original key algo will +// be returned instead. For proper CA support, use HostKeyDB.HostKeyAlgorithms. 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 +274,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 +316,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..4cd8ac6 100644 --- a/knownhosts_test.go +++ b/knownhosts_test.go @@ -7,14 +7,54 @@ 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) + } 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 +63,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 +133,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 +147,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 +388,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 +439,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 +455,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) From 53a26ccd67909a2b2a5cfad598c6ba7c860996d2 Mon Sep 17 00:00:00 2001 From: Evan Elias Date: Tue, 9 Jul 2024 16:27:21 -0400 Subject: [PATCH 3/3] Minor adjustments based on initial PR feedback * Add new exported method HostKeyCallback.ToDB, to provide a mechanism for callers who want to conditionally enable or disable CA support, while still using a *HostKeyDB for both cases. * Clarify many doc string comments. * Add new exported function WriteKnownHostCA for writing a @cert-authority line to a known_hosts file. Previously this logic was in a test helper, but it could be useful to others, so let's export it outside of the tests. --- example_test.go | 1 - knownhosts.go | 91 +++++++++++++++++++++++++++++----------------- knownhosts_test.go | 10 +---- 3 files changed, 58 insertions(+), 44 deletions(-) diff --git a/example_test.go b/example_test.go index 0df27cc..7a502d9 100644 --- a/example_test.go +++ b/example_test.go @@ -90,5 +90,4 @@ func ExampleWriteKnownHost() { log.Fatal("Failed to dial: ", err) } defer client.Close() - } diff --git a/knownhosts.go b/knownhosts.go index 1aeeb60..f1ff837 100644 --- a/knownhosts.go +++ b/knownhosts.go @@ -19,10 +19,8 @@ import ( ) // 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. It fully supports @cert-authority lines as well, and can -// return ssh.CertAlgo* values when looking up algorithms. To create a -// HostKeyDB, use NewDB. +// 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" @@ -70,8 +68,10 @@ func NewDB(files ...string) (*HostKeyDB, error) { return hkdb, nil } -// HostKeyCallback returns an ssh.HostKeyCallback for use in -// ssh.ClientConfig.HostKeyCallback. +// 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 } @@ -88,6 +88,9 @@ 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. +// 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}} @@ -122,8 +125,9 @@ func (hkdb *HostKeyDB) HostKeys(hostWithPort string) (keys []PublicKey) { // 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. -// For @cert-authority lines, the returned algorithm will be the correct -// ssh.CertAlgo* value. +// 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 @@ -181,21 +185,19 @@ func keyTypeToCertAlgo(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 +// 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. // -// 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. +// 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. // -// 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. +// 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 @@ -203,6 +205,11 @@ type HostKeyCallback ssh.HostKeyCallback // 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 @@ -214,20 +221,30 @@ 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, -// use HostKeyDB.HostKeys instead. +// (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 { - // 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) + annotatedKeys := hkcb.ToDB().HostKeys(hostWithPort) rawKeys := make([]ssh.PublicKey, len(annotatedKeys)) for n, ak := range annotatedKeys { rawKeys[n] = ak.PublicKey @@ -244,13 +261,10 @@ func (hkcb HostKeyCallback) HostKeys(hostWithPort string) []ssh.PublicKey { // 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, use HostKeyDB.HostKeyAlgorithms. +// be returned instead. For proper CA support, see NewDB and +// HostKeyDB.HostKeyAlgorithms instead. func (hkcb HostKeyCallback) HostKeyAlgorithms(hostWithPort string) (algos []string) { - // 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) + return hkcb.ToDB().HostKeyAlgorithms(hostWithPort) } // HostKeyAlgorithms is a convenience function for performing host key algorithm @@ -259,7 +273,8 @@ func (hkcb HostKeyCallback) HostKeyAlgorithms(hostWithPort string) (algos []stri // 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, use HostKeyDB.HostKeyAlgorithms. +// 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) } @@ -314,7 +329,7 @@ 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 this package to provide additional known_hosts // management functionality. The hostname, remote, and key typically correspond @@ -338,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 4cd8ac6..473fc1a 100644 --- a/knownhosts_test.go +++ b/knownhosts_test.go @@ -7,12 +7,9 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/rsa" - "encoding/base64" - "fmt" "net" "os" "path/filepath" - "strings" "testing" "golang.org/x/crypto/ssh" @@ -483,17 +480,12 @@ func appendCertTestKnownHosts(t *testing.T, filePath, hostPattern, keyType strin 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 { + if err := WriteKnownHostCA(f, hostPattern, pubKey); err != nil { t.Fatalf("Unable to append @cert-authority line to %s: %v", filePath, err) } }