wishlist/client_auth.go
Carlos Alexandro Becker eeb49ba210
fix: lint issues
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
2024-08-29 09:53:08 -03:00

393 lines
11 KiB
Go

package wishlist
import (
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"github.com/charmbracelet/keygen"
"github.com/charmbracelet/log"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wishlist/home"
gossh "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/crypto/ssh/knownhosts"
"golang.org/x/term"
)
var errNoRemoteAgent = fmt.Errorf("no agent forwarded")
// remoteBestAuthMethod returns an auth method.
//
// it first tries to use ssh-agent, if that's not available, it creates and uses a new key pair.
func remoteBestAuthMethod(e *Endpoint, s ssh.Session, in io.Reader) ([]gossh.AuthMethod, agent.Agent, closers, error) {
var methods []gossh.AuthMethod
var agt agent.Agent
var closers closers
for _, m := range e.Authentications() {
switch m {
case authModePassword:
method, err := passwordAuth(e, in, s)
if err != nil {
return nil, nil, closers, err
}
methods = append(methods, method)
case authModeKeyboardInteractive:
methods = append(methods, keyboardInteractiveAuth(in, s))
case authModePublicKey:
method, a, cl, err := tryRemoteAuthAgent(s)
if err != nil || method != nil {
agt = a
methods = append(methods, method)
closers = append(closers, cl...)
}
newKey, err := tryNewKey()
if err != nil {
return nil, nil, nil, err
}
methods = append(methods, newKey)
}
}
return methods, agt, closers, nil
}
// localBestAuthMethod figures out which authentication method is the best for
// the given endpoint.
//
// preference order:
// - the IdentityFiles, if they were set in the endpoint
// - the local ssh agent, if available
// - common key filenames under ~/.ssh/
//
// If any of the methods fails, it returns an error.
// It'll return a nil list if none of the methods is available.
func localBestAuthMethod(agt agent.Agent, e *Endpoint, in io.Reader, out io.Writer) ([]gossh.AuthMethod, error) {
var methods []gossh.AuthMethod
for _, m := range e.Authentications() {
switch m {
case authModePassword:
method, err := passwordAuth(e, in, out)
if err != nil {
return nil, err
}
methods = append(methods, method)
case authModeKeyboardInteractive:
methods = append(methods, keyboardInteractiveAuth(in, out))
case authModePublicKey:
if len(e.IdentityFiles) > 0 {
ids, err := tryIdendityFiles(e)
if err != nil {
return methods, err
}
methods = append(methods, ids...)
}
if method := agentAuthMethod(agt); method != nil {
methods = append(methods, method)
}
keys, err := tryUserKeys()
if err != nil {
return nil, err
}
methods = append(methods, keys...)
}
}
return methods, nil
}
// agentAuthMethod setups an auth method for the given agent.
func agentAuthMethod(agt agent.Agent) gossh.AuthMethod {
if agt == nil {
return nil
}
signers, _ := agt.Signers()
for _, signer := range signers {
log.Info(
"offering public key via ssh agent",
"key.type", signer.PublicKey().Type(),
"key.fingerprint", gossh.FingerprintSHA256(signer.PublicKey()),
)
}
return gossh.PublicKeysCallback(agt.Signers)
}
// getLocalAgent checks if there's a local agent at $SSH_AUTH_SOCK and, if so,
// returns a connection to it through agent.Agent.
func getLocalAgent() (agent.Agent, closers, error) {
socket := os.Getenv("SSH_AUTH_SOCK")
if socket == "" {
return nil, nil, nil
}
if _, err := os.Stat(socket); errors.Is(err, os.ErrNotExist) {
return nil, nil, nil
}
conn, err := net.Dial("unix", socket)
if err != nil {
return nil, nil, fmt.Errorf("failed to connect to SSH_AUTH_SOCK: %w", err)
}
return agent.NewClient(conn), closers{conn.Close}, nil
}
func getRemoteAgent(s ssh.Session) (agent.Agent, closers, error) {
_, _ = s.SendRequest("auth-agent-req@openssh.com", true, nil)
if !ssh.AgentRequested(s) {
return nil, nil, errNoRemoteAgent
}
l, err := ssh.NewAgentListener()
if err != nil {
return nil, nil, err //nolint:wrapcheck
}
go ssh.ForwardAgentConnections(l, s)
conn, err := net.Dial(l.Addr().Network(), l.Addr().String())
if err != nil {
return nil, closers{l.Close}, err //nolint:wrapcheck
}
return agent.NewClient(conn), closers{l.Close, conn.Close}, nil
}
// tryRemoteAuthAgent will try to use an ssh-agent to authenticate.
func tryRemoteAuthAgent(s ssh.Session) (gossh.AuthMethod, agent.Agent, closers, error) {
agent, closers, err := getRemoteAgent(s)
if err != nil {
if errors.Is(err, errNoRemoteAgent) {
wish.Errorln(s, fmt.Errorf("wishlist: ssh agent not available"))
return nil, nil, closers, nil
}
return nil, nil, closers, err
}
signers, _ := agent.Signers()
for _, signer := range signers {
log.Info(
"offering public key via ssh agent",
"key.type", signer.PublicKey().Type(),
"key.fingerprint", gossh.FingerprintSHA256(signer.PublicKey()),
)
}
return gossh.PublicKeysCallback(agent.Signers), agent, closers, nil
}
// tryNewKey will create a .wishlist/client_ed25519 keypair if one does not exist.
// It will return an auth method that uses the keypair if it exist or is successfully created.
func tryNewKey() (gossh.AuthMethod, error) {
path, err := filepath.Abs(".wishlist/client_ed25519")
if err != nil {
return nil, fmt.Errorf("could not create client key: %w", err)
}
key, err := keygen.New(path, keygen.WithKeyType(keygen.Ed25519))
if err != nil {
return nil, fmt.Errorf("could not create new client key at %q: %w", path, err)
}
signer := key.Signer()
log.Info(
"offering public key",
"key.path", path,
"key.type", signer.PublicKey().Type(),
"key.fingerprint", gossh.FingerprintSHA256(signer.PublicKey()),
)
if !key.KeyPairExists() {
if err := key.WriteKeys(); err != nil {
return nil, fmt.Errorf("could not write key: %w", err)
}
}
return gossh.PublicKeys(signer), nil
}
func tryIdendityFiles(e *Endpoint) ([]gossh.AuthMethod, error) {
methods := make([]gossh.AuthMethod, 0, len(e.IdentityFiles))
for _, id := range e.IdentityFiles {
method, err := tryIdentityFile(id)
if err != nil {
return nil, err
}
methods = append(methods, method)
}
return methods, nil
}
// tryIdentityFile tries to use the given idendity file.
func tryIdentityFile(id string) (gossh.AuthMethod, error) {
h, err := home.ExpandPath(id)
if err != nil {
return nil, err //nolint: wrapcheck
}
return parsePrivateKey(h, nil)
}
// tryUserKeys will try to find id_rsa and id_ed25519 keys in the user $HOME/~.ssh folder.
func tryUserKeys() ([]gossh.AuthMethod, error) {
return tryUserKeysInternal(home.ExpandPath)
}
// https://github.com/openssh/openssh-portable/blob/8a0848cdd3b25c049332cd56034186b7853ae754/readconf.c#L2534-L2546
// https://github.com/openssh/openssh-portable/blob/2dc328023f60212cd29504fc05d849133ae47355/pathnames.h#L71-L81
func tryUserKeysInternal(pathResolver func(string) (string, error)) ([]gossh.AuthMethod, error) {
var methods []gossh.AuthMethod //nolint: prealloc
for _, name := range []string{
"id_rsa",
// "id_dsa", // unhandled by go, deprecated by openssh
"id_ecdsa",
"id_ecdsa_sk",
"id_ed25519",
"id_ed25519_sk",
// "id_xmss", // unhandled by go - and most openssh versions it seems
} {
path, err := pathResolver(filepath.Join("~/.ssh", name))
if err != nil {
return nil, err
}
method, err := parsePrivateKey(path, nil)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return methods, err
}
methods = append(methods, method)
}
return methods, nil
}
func parsePrivateKey(path string, password []byte) (gossh.AuthMethod, error) {
path, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("could not find key: %q: %w", path, err)
}
bts, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read key: %q: %w", path, err)
}
var signer gossh.Signer
if len(password) == 0 {
signer, err = gossh.ParsePrivateKey(bts)
} else {
signer, err = gossh.ParsePrivateKeyWithPassphrase(bts, password)
}
if err != nil {
pwderr := &gossh.PassphraseMissingError{}
if errors.As(err, &pwderr) {
fmt.Printf("Enter the password for %q: ", path)
// #nosec G115
password, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return nil, fmt.Errorf("failed to read password: %q", err)
}
return parsePrivateKey(path, password)
}
return nil, fmt.Errorf("failed to parse private key: %q: %w", path, err)
}
log.Info(
"offering public key",
"key.path", path,
"key.type", signer.PublicKey().Type(),
"key.fingerprint", gossh.FingerprintSHA256(signer.PublicKey()),
)
return gossh.PublicKeys(signer), nil
}
// hostKeyCallback returns a callback that will be used to verify the host key.
//
// it creates a file in the given path, and uses that to verify hosts and keys.
// if the host does not exist there, it adds it so its available next time, as plain old `ssh` does.
func hostKeyCallback(e *Endpoint, path string) gossh.HostKeyCallback {
return func(hostname string, remote net.Addr, key gossh.PublicKey) error {
kh, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:mnd
if err != nil {
return fmt.Errorf("failed to open known_hosts: %w", err)
}
defer func() { _ = kh.Close() }()
callback, err := knownhosts.New(kh.Name())
if err != nil {
return fmt.Errorf("failed to check known_hosts: %w", err)
}
if err := callback(hostname, remote, key); err != nil {
var kerr *knownhosts.KeyError
if errors.As(err, &kerr) {
if len(kerr.Want) > 0 {
return fmt.Errorf("possible man-in-the-middle attack: %w - if your host's key changed, you might need to edit %q", err, kh.Name())
}
// if want is empty, it means the host was not in the known_hosts file, so lets add it there.
fmt.Fprintln(kh, knownhosts.Line([]string{e.Address}, key)) //nolint: errcheck
return nil
}
return fmt.Errorf("failed to check known_hosts: %w", err)
}
return nil
}
}
// #nosec G115
func askUser(in io.Reader, echo bool) (string, error) {
if !echo {
if f, ok := in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
bts, err := term.ReadPassword(int(f.Fd()))
if err != nil {
return "", fmt.Errorf("could not scan: %w", err)
}
return string(bts), nil
}
log.Warn("stdin is not a terminal, can't disable echo")
}
var answer string
if _, err := fmt.Fscan(in, &answer); err != nil {
return "", fmt.Errorf("could not scan: %w", err)
}
return answer, nil
}
// keyboardInteractiveAuth implements keyboard interactive authentication.
func keyboardInteractiveAuth(in io.Reader, out io.Writer) gossh.AuthMethod {
scan := func(q string, echo bool) (string, error) {
fmt.Fprint(out, q+" ") //nolint: errcheck
answer, err := askUser(in, echo)
if err != nil {
return "", err
}
fmt.Fprintln(out) //nolint: errcheck
return answer, nil
}
return gossh.KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
fmt.Fprintln(out, name) //nolint: errcheck
fmt.Fprintln(out, instruction) //nolint: errcheck
for i, q := range questions {
answer, err := scan(q, echos[i])
if err != nil {
return nil, err
}
answers = append(answers, answer)
}
return answers, nil
})
}
func passwordAuth(e *Endpoint, in io.Reader, out io.Writer) (gossh.AuthMethod, error) {
fmt.Fprintf(out, "%s password: ", e.Address) //nolint: errcheck
secret, err := askUser(in, false)
if err != nil {
return nil, fmt.Errorf("could not read password: %w", err)
}
return gossh.Password(secret), nil
}