etcd: ssh: Improve the authentication for ssh etcd world

This was rather tricky, but I think I've learned a lot more about how
SSH actually works. We now only offer up to the server what we can
actually support, which lets us actually get back a host key we have a
chance of actually authenticating against.

Needed a new version of the ssh code and had to mess with go mod
garbage.
This commit is contained in:
James Shubin
2025-06-08 03:07:59 -04:00
parent 1ccec72a7c
commit f594799a7f
3 changed files with 226 additions and 50 deletions

View File

@@ -38,6 +38,8 @@ import (
"net"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -58,9 +60,9 @@ const (
defaultSSHPort uint16 = 22
defaultSSHHostKeyFieldName = "hostkey" // querystring field name
defaultEtcdPort uint16 = 2379 // TODO: get this from etcd pkg
defaultIDRsaPath = "~/.ssh/id_rsa"
defaultIDEd25519Path = "~/.ssh/id_ed25519"
defaultSSHDir = "~/.ssh/"
defaultKnownHostsPath = "~/.ssh/known_hosts"
allowRSA = true // are big keys okay?
)
// World is an implementation of the world API for etcd over SSH.
@@ -82,11 +84,10 @@ type World struct {
// is specified, then it overrides looking for it in the URL.
HostKey string
// SSHID is the path to the ~/.ssh/id_rsa or ~/.ssh/id_ed25519 to use
// for auth. If you omit this then this will look for your private key
// in both of those default paths. If you specific a specific path, then
// that will only be used. This will expand the ~/ and ~user/ style path
// expansions.
// SSHID is the path to the ~/.ssh/id_??? key to use for auth. If you
// omit this then this will look for your private key in all possible
// paths. If you specific a specific path, then only that will be used.
// This will expand the ~/ and ~user/ style path expansions.
SSHID string
// Seeds are the list of etcd endpoints to connect to.
@@ -109,35 +110,125 @@ type World struct {
cleanups []func() error
}
// sshKeyAuth is a helper function to get the ssh key auth struct needed.
func (obj *World) sshKeyAuth(sshID string) (ssh.AuthMethod, error) {
if sshID == "" {
return nil, fmt.Errorf("empty path specified")
}
// expand strings of the form: ~james/.ssh/id_rsa
p, err := util.ExpandHome(sshID)
// keySigners gets a list of possible key signers. These are used to get the
// available types of the keys, and the auth methods.
func (obj *World) keySigners() ([]ssh.Signer, error) {
sshDir, err := util.ExpandHome(defaultSSHDir)
if err != nil {
return nil, errwrap.Wrapf(err, "can't find home directory")
}
if p == "" {
return nil, fmt.Errorf("empty path specified")
if sshDir == "" {
return nil, fmt.Errorf("empty path found")
}
files, err := os.ReadDir(sshDir)
if err != nil {
return nil, err
}
signers := []ssh.Signer{}
// XXX: Should we aim to pull the keys out by order of preference?
for _, file := range files {
p := filepath.Join(sshDir, file.Name())
if file.IsDir() || obj.isPossiblePrivateKeyFile(p) != nil {
continue
}
signer, err := obj.keySigner(p)
if err != nil {
obj.init.Logf("%s", err)
continue
}
signers = append(signers, signer)
}
return signers, nil
}
// keySigner returns a single signer from an absolute path.
func (obj *World) keySigner(p string) (ssh.Signer, error) {
data, err := os.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("key file error: %s", err)
}
if len(data) == 0 {
return nil, fmt.Errorf("empty key file at: %s", p)
}
// A public key may be used to authenticate against the server by using
// an unencrypted PEM-encoded private key file. If you have an encrypted
// private key, the crypto/x509 package can be used to decrypt it.
key, err := os.ReadFile(p)
signer, err := ssh.ParsePrivateKey(data)
if err != nil {
return nil, err
if _, ok := err.(*ssh.PassphraseMissingError); ok {
return nil, fmt.Errorf("password required for key file:: %s", p)
}
return nil, fmt.Errorf("key file parsing error: %s", err)
}
// create the Signer for this private key
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return nil, err
obj.init.Logf("found auth option in: %s", p)
// return the Signer for this private key
return signer, nil
}
// isPossiblePrivateKeyFile determines if we've found a private key file.
func (obj *World) isPossiblePrivateKeyFile(p string) error {
b := filepath.Base(p)
//d := filepath.Dir(p) // no trailing slash :(
if !strings.HasPrefix(b, "id_") {
return fmt.Errorf("keys start with id_???")
}
return ssh.PublicKeys(signer), nil
if strings.HasSuffix(b, ".pub") {
return fmt.Errorf("this is a public key")
}
if _, err := os.Stat(p + ".pub"); err != nil {
return fmt.Errorf("matching public key is inaccessible")
}
// TODO: should we rule out anything else?
return nil
}
// prioritizeHostKeyAlgorithms returns the host key algorithms that we tell the
// server that we support. The order matters, because this ordering will let the
// server know which we can authenticate against. Once we send a list, the
// server then only returns a single one, so it's important that we sort this
// list properly with what we have available at the very top.
func (obj *World) prioritizeHostKeyAlgorithms(allHostKeyAlgos, keyTypes []string) []string {
rank := make(map[string]int, len(keyTypes))
for i, t := range keyTypes {
rank[t] = i
}
sorted := make([]string, len(allHostKeyAlgos))
copy(sorted, allHostKeyAlgos)
sort.SliceStable(sorted, func(i, j int) bool {
rankI, okI := rank[sorted[i]]
rankJ, okJ := rank[sorted[j]]
switch {
case okI && okJ:
return rankI < rankJ
case okI:
return true
case okJ:
return false
default:
return false
}
})
return sorted
}
// knownHostsKey takes a known_hosts key entry (just the base64 key part) and
@@ -167,13 +258,17 @@ func (obj *World) knownHostsKey(hostkey string) (ssh.PublicKey, error) {
// func (obj *World) hostKeyCallback() (ssh.HostKeyCallback, error) {
func (obj *World) hostKeyCallback(hostkey ssh.PublicKey) ssh.HostKeyCallback {
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
obj.init.Logf("server host key type: %s", key.Type())
obj.init.Logf("host key fingerprint: %s", ssh.FingerprintSHA256(key))
// First try our one known key if it exists.
if hostkey != nil {
fn := ssh.FixedHostKey(hostkey)
if fn(hostname, remote, key) == nil {
obj.init.Logf("matched key")
return nil // found it!
}
obj.init.Logf("did not match known key: %s", ssh.FingerprintSHA256(hostkey))
}
// TODO: consider allowing a user-specified path in the future
@@ -192,7 +287,35 @@ func (obj *World) hostKeyCallback(hostkey ssh.PublicKey) ssh.HostKeyCallback {
if err != nil {
return err
}
return fn(hostname, remote, key)
obj.init.Logf("trying known_hosts file at: %s", p)
err = fn(hostname, remote, key)
if err == nil {
obj.init.Logf("host key matched")
return nil
}
ke, ok := err.(*knownhosts.KeyError) // give a better error?
if !ok || len(ke.Want) == 0 {
return err
}
// Based on what we initially have in our ~/.ssh/ dir, our ssh
// client offers keys to the server differently, and the server
// replies with up to one of our acceptable choices. If none are
// available, then this error message is weird, so we do all
// this to make it clearer.
types := []string{}
for _, kk := range ke.Want { // known keys
typ := kk.Key.Type()
types = append(types, typ)
// We found what the server offered, error normally...
if key.Type() == typ {
return err
}
}
return fmt.Errorf("no known_hosts entry matching type, have: %s", strings.Join(types, ", "))
}
}
@@ -267,40 +390,76 @@ func (obj *World) Connect(ctx context.Context, init *engine.WorldInit) error {
addr := fmt.Sprintf("%s:%s", hostname, port)
// Preference order of keys I have available...
keyTypes := []string{
//ssh.KeyAlgoED25519, // "ssh-ed25519"
//ssh.KeyAlgoRSA, // "ssh-rsa"
}
auths := []ssh.AuthMethod{}
//auths = append(auths, ssh.Password("password")) // testing
choices := []string{
//obj.SSHID,
defaultIDEd25519Path,
defaultIDRsaPath, // "~/.ssh/id_rsa"
}
if obj.SSHID != "" {
choices = []string{
obj.SSHID,
}
}
for _, p := range choices {
if p == "" {
continue
}
auth, err := obj.sshKeyAuth(p)
p, err := util.ExpandHome(obj.SSHID)
if err != nil {
//obj.init.Logf("can't get auth from: %s", p) // misleading
continue
return errwrap.Wrapf(err, "can't find home directory")
}
obj.init.Logf("found auth option in: %s", p)
auths = append(auths, auth)
if p == "" {
return fmt.Errorf("empty path specified")
}
signer, err := obj.keySigner(p)
if err != nil {
return err
}
typ := signer.PublicKey().Type()
keyTypes = append(keyTypes, typ)
auths = append(auths, ssh.PublicKeys(signer)) // add one
}
if len(auths) == 0 {
signers, err := obj.keySigners()
if err != nil {
return err
}
for _, signer := range signers {
typ := signer.PublicKey().Type()
keyTypes = append(keyTypes, typ)
}
// TODO: should the order of the signers matter?
if len(signers) > 0 {
auths = append(auths, ssh.PublicKeys(signers...)) // add all
}
}
if len(auths) == 0 {
return fmt.Errorf("no auth options available")
}
obj.init.Logf("found %d available key types: %s", len(keyTypes), strings.Join(keyTypes, ", "))
algorithms := ssh.SupportedAlgorithms()
preferredAlgoOrder := algorithms.HostKeys // the defaults
if allowRSA {
preferredAlgoOrder = append(preferredAlgoOrder, ssh.KeyAlgoRSA)
}
obj.init.Logf("supported algos: %s", strings.Join(preferredAlgoOrder, ", "))
// SSH connection configuration
sshConfig := &ssh.ClientConfig{
User: user,
Auth: auths,
//HostKeyCallback: ssh.InsecureIgnoreHostKey(), // testing
HostKeyCallback: obj.hostKeyCallback(pubKey),
// This is the list of host key algorithms that this SSH client
// will offer to the SSH server when it says hello. This can be
// different from what a normal terminal SSH client might do,
// which means you might not get the right SSH host key algo
// offered back to you, so make sure you provide what it's
// asking for. Maybe we need to make this configurable by the
// user.
//HostKeyAlgorithms: algorithms.HostKeys,
HostKeyAlgorithms: obj.prioritizeHostKeyAlgorithms(preferredAlgoOrder, keyTypes),
}
obj.init.Logf("ssh: %s@%s", user, addr)