mirror of
https://github.com/charmbracelet/wishlist.git
synced 2025-11-08 23:05:04 -06:00
393 lines
11 KiB
Go
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
|
|
}
|