From 7ad1b7ef1df7e61cb62cb218a8a411ff64500be3 Mon Sep 17 00:00:00 2001 From: bbedward Date: Fri, 30 Oct 2026 10:35:55 -0400 Subject: [PATCH] plugins: work around x/crypto rejecting ssh-mldsa44-ed25519 known_hosts entries fixes #4029 port 2.0 --- core/go.mod | 4 +- core/internal/gitssh/gitssh.go | 129 ++++++++++++++++++++++++++++++ core/internal/plugins/registry.go | 12 +-- core/internal/themes/registry.go | 8 +- 4 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 core/internal/gitssh/gitssh.go diff --git a/core/go.mod b/core/go.mod index 6d21aaa20..3ec5ed96b 100644 --- a/core/go.mod +++ b/core/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-git/go-git/v6 v6.0.0-beta.1 github.com/godbus/dbus/v5 v5.2.4 github.com/holoplot/go-evdev v0.0.0-20260909133534-d873ed210bda + github.com/kevinburke/ssh_config v1.6.0 github.com/klauspost/compress v1.20.3 github.com/lucasb-eyer/go-colorful v1.4.1 github.com/mattn/go-isatty v0.0.24 @@ -31,6 +32,7 @@ require ( github.com/yuin/goldmark v1.8.6 go.etcd.io/bbolt v1.5.0 go4.org/mem v0.0.0-20240501181205-ae6ca9944745 + golang.org/x/crypto v0.58.0 golang.org/x/image v0.47.0 golang.org/x/sys v0.49.0 golang.org/x/text v0.43.0 @@ -71,7 +73,6 @@ require ( github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jsimonetti/rtnetlink v1.4.2 // indirect - github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect github.com/mattn/go-runewidth v0.0.31 // indirect @@ -97,7 +98,6 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect - golang.org/x/crypto v0.58.0 // indirect golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect golang.org/x/net v0.61.0 // indirect golang.org/x/sync v0.24.0 // indirect diff --git a/core/internal/gitssh/gitssh.go b/core/internal/gitssh/gitssh.go new file mode 100644 index 000000000..1561a61d6 --- /dev/null +++ b/core/internal/gitssh/gitssh.go @@ -0,0 +1,129 @@ +package gitssh + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "net" + "net/url" + "os" + "path/filepath" + "strconv" + + "github.com/go-git/go-git/v6/plumbing/client" + "github.com/go-git/go-git/v6/plumbing/transport" + xssh "github.com/go-git/go-git/v6/plumbing/transport/ssh" + "github.com/go-git/go-git/v6/plumbing/transport/ssh/knownhosts" + "github.com/kevinburke/ssh_config" + gossh "golang.org/x/crypto/ssh" +) + +func ClientOptions() []client.Option { + return []client.Option{client.WithSSHAuth(agentAuth{})} +} + +type agentAuth struct{} + +func (agentAuth) ClientConfig(ctx context.Context, req *transport.Request) (*gossh.ClientConfig, error) { + auth, err := xssh.NewSSHAgentAuth(username(req.URL)) + if err != nil { + return nil, err + } + db, err := knownHostsDB() + if err != nil { + return nil, err + } + auth.HostKeyCallback = db.HostKeyCallback() + cfg, err := auth.ClientConfig(ctx, req) + if err != nil { + return nil, err + } + cfg.HostKeyAlgorithms = db.HostKeyAlgorithms(hostWithPort(req.URL)) + if len(cfg.HostKeyAlgorithms) == 0 { + cfg.HostKeyAlgorithms = gossh.SupportedAlgorithms().HostKeys + } + return cfg, nil +} + +func username(u *url.URL) string { + if u.User == nil || u.User.Username() == "" { + return xssh.DefaultUsername + } + return u.User.Username() +} + +func hostWithPort(u *url.URL) string { + hostname := u.Hostname() + port := u.Port() + if configHost := ssh_config.Get(hostname, "Hostname"); configHost != "" { + hostname = configHost + } + if port == "" { + if configPort := ssh_config.Get(u.Hostname(), "Port"); configPort != "" { + if _, err := strconv.Atoi(configPort); err == nil { + port = configPort + } + } + } + if port == "" { + port = strconv.Itoa(xssh.DefaultPort) + } + return net.JoinHostPort(hostname, port) +} + +// x/crypto rejects a whole known_hosts file over one key type it cannot parse, +// so entries like ssh-mldsa44-ed25519 are blanked before the file is loaded. +func knownHostsDB() (*knownhosts.HostKeyDB, error) { + dir, err := os.MkdirTemp("", "dms-known-hosts-") + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + + var filtered []string + for i, file := range knownHostsFiles() { + content, err := os.ReadFile(file) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return nil, err + } + path := filepath.Join(dir, fmt.Sprintf("%d-%s", i, filepath.Base(file))) + if err := os.WriteFile(path, blankUnparseableLines(content), 0o600); err != nil { + return nil, err + } + filtered = append(filtered, path) + } + if len(filtered) == 0 { + return nil, errors.New("unable to find any valid known_hosts file, set SSH_KNOWN_HOSTS env variable") + } + return knownhosts.NewDB(filtered...) +} + +func knownHostsFiles() []string { + if files := filepath.SplitList(os.Getenv("SSH_KNOWN_HOSTS")); len(files) != 0 { + return files + } + home, err := os.UserHomeDir() + if err != nil { + return []string{"/etc/ssh/ssh_known_hosts"} + } + return []string{filepath.Join(home, ".ssh", "known_hosts"), "/etc/ssh/ssh_known_hosts"} +} + +func blankUnparseableLines(content []byte) []byte { + lines := bytes.Split(content, []byte("\n")) + for i, line := range lines { + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 || trimmed[0] == '#' { + continue + } + if _, _, _, _, _, err := gossh.ParseKnownHosts(trimmed); err != nil { + lines[i] = nil + } + } + return bytes.Join(lines, []byte("\n")) +} diff --git a/core/internal/plugins/registry.go b/core/internal/plugins/registry.go index ac76befda..52f7488b3 100644 --- a/core/internal/plugins/registry.go +++ b/core/internal/plugins/registry.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/duxlabs-dev/dux-shell/core/internal/gitssh" "github.com/duxlabs-dev/dux-shell/core/internal/registries" "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" @@ -44,8 +45,9 @@ type realGitClient struct{} func (g *realGitClient) PlainClone(path string, url string) error { _, err := git.PlainClone(path, &git.CloneOptions{ - URL: url, - Progress: os.Stdout, + URL: url, + Progress: os.Stdout, + ClientOptions: gitssh.ClientOptions(), }) return err } @@ -61,7 +63,7 @@ func (g *realGitClient) Pull(path string) error { return err } - err = worktree.Pull(&git.PullOptions{}) + err = worktree.Pull(&git.PullOptions{ClientOptions: gitssh.ClientOptions()}) if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { return err } @@ -101,7 +103,7 @@ func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) { return false, "", "", err } - refs, err := remote.List(&git.ListOptions{}) + refs, err := remote.List(&git.ListOptions{ClientOptions: gitssh.ClientOptions()}) if err != nil { return false, "", "", err } @@ -168,7 +170,7 @@ func (g *realGitClient) CheckoutRevision(path string, revision string) error { if _, remoteErr := repo.Remote("origin"); remoteErr != nil { return fmt.Errorf("revision %s is not available in repository: %w", revision, objectErr) } - fetchErr := repo.Fetch(&git.FetchOptions{}) + fetchErr := repo.Fetch(&git.FetchOptions{ClientOptions: gitssh.ClientOptions()}) if fetchErr != nil && fetchErr != git.NoErrAlreadyUpToDate { return fmt.Errorf("failed to fetch locked revision: %w", fetchErr) } diff --git a/core/internal/themes/registry.go b/core/internal/themes/registry.go index 6f3ad69ee..b3bef70a0 100644 --- a/core/internal/themes/registry.go +++ b/core/internal/themes/registry.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" + "github.com/duxlabs-dev/dux-shell/core/internal/gitssh" "github.com/duxlabs-dev/dux-shell/core/internal/registries" "github.com/go-git/go-git/v6" "github.com/spf13/afero" @@ -157,8 +158,9 @@ type realGitClient struct{} func (g *realGitClient) PlainClone(path string, url string) error { _, err := git.PlainClone(path, &git.CloneOptions{ - URL: url, - Progress: os.Stdout, + URL: url, + Progress: os.Stdout, + ClientOptions: gitssh.ClientOptions(), }) return err } @@ -174,7 +176,7 @@ func (g *realGitClient) Pull(path string) error { return err } - err = worktree.Pull(&git.PullOptions{}) + err = worktree.Pull(&git.PullOptions{ClientOptions: gitssh.ClientOptions()}) if err != nil && err.Error() != "already up-to-date" { return err }