feat: create first pass known_hosts detection

Manually integrating with golang's ssh library means that the code must
authenticate known_hosts on its own. This is a first pass at creating a
system that parses the known hosts files and denys a connection if there
is a mismatch. This needs to be updated with a means to add keys to the
known-hosts file if the user requests it.
This commit is contained in:
Sylvia Crowe 2024-02-02 11:50:02 -08:00
parent 26240bea97
commit e2d10cd807

View File

@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"log"
"net"
"os"
"os/user"
"strconv"
@ -18,6 +19,7 @@ import (
"github.com/wavetermdev/waveterm/waveshell/pkg/base"
"github.com/wavetermdev/waveterm/wavesrv/pkg/sstore"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
func createPublicKeyAuth(identityFile string, passphrase string) (ssh.AuthMethod, error) {
@ -53,6 +55,127 @@ func createKeyboardInteractiveAuth(password string) ssh.AuthMethod {
return ssh.KeyboardInteractive(challenge)
}
func createHostKeyCallback(opts *sstore.SSHOpts) (ssh.HostKeyCallback, error) {
rawUserKnownHostsFiles, _ := ssh_config.GetStrict(opts.SSHHost, "UserKnownHostsFile")
userKnownHostsFiles := strings.Fields(rawUserKnownHostsFiles) // TODO - smarter splitting escaped spaces and quotes
rawGlobalKnownHostsFiles, _ := ssh_config.GetStrict(opts.SSHHost, "GlobalKnownHostsFile")
globalKnownHostsFiles := strings.Fields(rawGlobalKnownHostsFiles) // TODO - smarter splitting escaped spaces and quotes
unexpandedKnownHostsFiles := append(userKnownHostsFiles, globalKnownHostsFiles...)
var knownHostsFiles []string
for _, filename := range unexpandedKnownHostsFiles {
knownHostsFiles = append(knownHostsFiles, base.ExpandHomeDir(filename))
}
var unfilteredKnownHostsFiles []string
copy(unfilteredKnownHostsFiles, knownHostsFiles)
// the library we use isn't very forgiving about files that are formatted
// incorrectly. if a problem file is found, it is removed from our list
// and we try again
var basicCallback ssh.HostKeyCallback
for basicCallback == nil && len(knownHostsFiles) > 0 {
var err error
basicCallback, err = knownhosts.New(knownHostsFiles...)
if serr, ok := err.(*os.PathError); ok {
badFile := serr.Path
var okFiles []string
for _, filename := range knownHostsFiles {
if filename != badFile {
okFiles = append(okFiles, filename)
}
}
if len(okFiles) >= len(knownHostsFiles) {
return nil, fmt.Errorf("problem file (%s) doesn't exist. this should not be possible", badFile)
}
knownHostsFiles = okFiles
} else if err != nil {
// TODO handle obscure problems if possible
return nil, fmt.Errorf("known_hosts formatting error: %+v", err)
}
}
// determine which file is writeable in case the key is not found.
// use knownHostsFiles because there is no point reading to a file
// that we can't parse
var writeableKnownHostsFile string
for _, filename := range knownHostsFiles {
f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0644)
if err == nil {
f.Close()
writeableKnownHostsFile = filename
break
}
}
if len(knownHostsFiles) == 0 {
// TODO attempt to create a known host file
return nil, fmt.Errorf("there are no known_host files that can be opened")
}
waveHostKeyCallback := func(hostname string, remote net.Addr, key ssh.PublicKey) error {
err := basicCallback(hostname, remote, key)
if err == nil {
// success
return nil
} else if _, ok := err.(*knownhosts.RevokedError); ok {
// revoked credentials are refused outright
return fmt.Errorf("foo")
} else if _, ok := err.(*knownhosts.KeyError); !ok {
// this is an unknown error
return fmt.Errorf("bar")
}
serr, _ := err.(*knownhosts.KeyError)
var request *sstore.UserInputRequestType
ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second)
defer cancelFn()
if writeableKnownHostsFile == "" {
if len(unfilteredKnownHostsFiles) == 0 {
return fmt.Errorf("no known_hosts files provided")
}
knownHostsFileToCreate := unfilteredKnownHostsFiles[0]
request = &sstore.UserInputRequestType{
ResponseType: "confirm",
QueryText: fmt.Sprintf("You do not have appear to have a known_hosts file in any of\n\n"+
"the expected locations. Would you like to create %s and add the key for %s (%s) to it?",
knownHostsFileToCreate, hostname, remote.String()),
Markdown: true,
Title: "Known Hosts Key Missing",
}
} else if len(serr.Want) == 0 {
request = &sstore.UserInputRequestType{
ResponseType: "confirm",
QueryText: fmt.Sprintf("The authenticity of host '%s (%s)' can't be established.\n\n"+
"%s key fingerprint is %s.\n\nThe key is not known by any other names.\n\nAre you sure"+
"you want to continue connecting?", hostname, remote.String(), key.Type(), "TODO"),
Markdown: true,
Title: "Known Hosts Key Missing",
}
} else {
request = &sstore.UserInputRequestType{
ResponseType: "confirm",
QueryText: fmt.Sprintf("The key provided does not match the one stored in your known\n\n" +
"hosts file. If this is unexpected, it could indicate a man-in-the-middle attack. Are\n\n" +
"you sure you want to continue connecting?"),
Markdown: true,
Title: "Known Hosts Key Mismatch",
}
}
response, err := sstore.MainBus.GetUserInput(request, ctx)
if err != nil {
return err
}
if !response.Confirm {
return fmt.Errorf("canceled by the user")
}
// attempt to fix the problem
// try one final time
return basicCallback(hostname, remote, key)
}
return waveHostKeyCallback, nil
}
func ConnectToClient(opts *sstore.SSHOpts) (*ssh.Client, error) {
ssh_config.ReloadConfigs()
configIdentity, _ := ssh_config.GetStrict(opts.SSHHost, "IdentityFile")
@ -78,7 +201,10 @@ func ConnectToClient(opts *sstore.SSHOpts) (*ssh.Client, error) {
}
log.Printf("response: %s\n", response.Text)
hostKeyCallback := ssh.InsecureIgnoreHostKey()
hostKeyCallback, err := createHostKeyCallback(opts)
if err != nil {
return nil, fmt.Errorf("uh oh host key: %+v", err)
}
var authMethods []ssh.AuthMethod
publicKeyAuth, err := createPublicKeyAuth(identityFile, opts.SSHPassword)
if err == nil {