From 6a4ba6a68938ac412075efa387780cea9127f178 Mon Sep 17 00:00:00 2001 From: Wim <42wim@noreply.gitea.io> Date: Thu, 15 Sep 2022 03:00:08 +0800 Subject: [PATCH] Add support for authentication via ssh certificates and pub/privatekey (#442) This adds support for authentication using a SSH certificate and normal public keys when you've got an ssh-agent running that has this certificate or your public key loaded. First question when creating a new login is to ask about the ssh certificates or public keys, when the answer is yes, we don't need to ask about tokens/usernames anymore. Co-authored-by: Wim Reviewed-on: https://gitea.com/gitea/tea/pulls/442 Reviewed-by: Lunny Xiao Reviewed-by: 6543 <6543@obermui.de> Co-authored-by: Wim <42wim@noreply.gitea.io> Co-committed-by: Wim <42wim@noreply.gitea.io> --- cmd/login/add.go | 22 ++++++- modules/config/login.go | 30 ++++++++-- modules/interact/login.go | 79 +++++++++++++++++++------ modules/task/login_create.go | 43 +++++++++----- modules/task/login_httpsign.go | 105 +++++++++++++++++++++++++++++++++ modules/utils/utils.go | 23 ++++++++ 6 files changed, 262 insertions(+), 40 deletions(-) create mode 100644 modules/task/login_httpsign.go diff --git a/cmd/login/add.go b/cmd/login/add.go index a794b83..fd3adb5 100644 --- a/cmd/login/add.go +++ b/cmd/login/add.go @@ -53,13 +53,23 @@ var CmdLoginAdd = cli.Command{ &cli.StringFlag{ Name: "ssh-key", Aliases: []string{"s"}, - Usage: "Path to a SSH key to use, overrides auto-discovery", + Usage: "Path to a SSH key/certificate to use, overrides auto-discovery", }, &cli.BoolFlag{ Name: "insecure", Aliases: []string{"i"}, Usage: "Disable TLS verification", }, + &cli.StringFlag{ + Name: "ssh-agent-principal", + Aliases: []string{"c"}, + Usage: "Use SSH certificate with specified principal to login (needs a running ssh-agent with certificate loaded)", + }, + &cli.StringFlag{ + Name: "ssh-agent-key", + Aliases: []string{"a"}, + Usage: "Use SSH public key or SSH fingerprint to login (needs a running ssh-agent with ssh key loaded)", + }, }, Action: runLoginAdd, } @@ -70,6 +80,11 @@ func runLoginAdd(ctx *cli.Context) error { return interact.CreateLogin() } + sshAgent := false + if ctx.String("ssh-agent-key") != "" || ctx.String("ssh-agent-principal") != "" { + sshAgent = true + } + // else use args to add login return task.CreateLogin( ctx.String("name"), @@ -78,5 +93,8 @@ func runLoginAdd(ctx *cli.Context) error { ctx.String("password"), ctx.String("ssh-key"), ctx.String("url"), - ctx.Bool("insecure")) + ctx.String("ssh-agent-principal"), + ctx.String("ssh-agent-key"), + ctx.Bool("insecure"), + sshAgent) } diff --git a/modules/config/login.go b/modules/config/login.go index 5051650..bb35de7 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -15,6 +15,8 @@ import ( "strings" "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/utils" + "github.com/AlecAivazis/survey/v2" ) // Login represents a login to a gitea server, you even could add multiple logins for one gitea server @@ -25,8 +27,12 @@ type Login struct { Default bool `yaml:"default"` SSHHost string `yaml:"ssh_host"` // optional path to the private key - SSHKey string `yaml:"ssh_key"` - Insecure bool `yaml:"insecure"` + SSHKey string `yaml:"ssh_key"` + Insecure bool `yaml:"insecure"` + SSHCertPrincipal string `yaml:"ssh_certificate_principal"` + SSHAgent bool `yaml:"ssh_agent"` + SSHKeyFingerprint string `yaml:"ssh_key_agent_pub"` + SSHPassphrase string `yaml:"-"` // User is username from gitea User string `yaml:"user"` // Created is auto created unix timestamp @@ -132,7 +138,7 @@ func GetLoginByHost(host string) *Login { // DeleteLogin delete a login by name from config func DeleteLogin(name string) error { - var idx = -1 + idx := -1 for i, l := range config.Logins { if l.Name == name { idx = i @@ -172,11 +178,27 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client { Jar: cookieJar, Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }} + }, + } } options = append(options, gitea.SetToken(l.Token), gitea.SetHTTPClient(httpClient)) + if ok, err := utils.IsKeyEncrypted(l.SSHKey); ok && err == nil && l.SSHPassphrase == "" { + promptPW := &survey.Password{Message: "ssh-key is encrypted please enter the passphrase: "} + if err = survey.AskOne(promptPW, &l.SSHPassphrase, survey.WithValidator(survey.Required)); err != nil { + log.Fatal(err) + } + } + + if l.SSHCertPrincipal != "" { + options = append(options, gitea.UseSSHCert(l.SSHCertPrincipal, l.SSHKey, l.SSHPassphrase)) + } + + if l.SSHKeyFingerprint != "" { + options = append(options, gitea.UseSSHPubkey(l.SSHKeyFingerprint, l.SSHKey, l.SSHPassphrase)) + } + client, err := gitea.NewClient(l.URL, options...) if err != nil { log.Fatal(err) diff --git a/modules/interact/login.go b/modules/interact/login.go index 1520114..c1fadf6 100644 --- a/modules/interact/login.go +++ b/modules/interact/login.go @@ -6,6 +6,7 @@ package interact import ( "fmt" + "regexp" "strings" "code.gitea.io/tea/modules/task" @@ -15,8 +16,9 @@ import ( // CreateLogin create an login interactive func CreateLogin() error { - var name, token, user, passwd, sshKey, giteaURL string + var name, token, user, passwd, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint string var insecure = false + var sshAgent = false promptI := &survey.Input{Message: "URL of Gitea instance: "} if err := survey.AskOne(promptI, &giteaURL, survey.WithValidator(survey.Required)); err != nil { @@ -38,34 +40,75 @@ func CreateLogin() error { return err } - var hasToken bool - promptYN := &survey.Confirm{ - Message: "Do you have an access token?", - Default: false, - } - if err = survey.AskOne(promptYN, &hasToken); err != nil { + loginMethod, err := promptSelect("Login with: ", []string{"token", "ssh-key/certificate"}, "", "") + if err != nil { return err } - if hasToken { - promptI = &survey.Input{Message: "Token: "} - if err := survey.AskOne(promptI, &token, survey.WithValidator(survey.Required)); err != nil { - return err + switch loginMethod { + case "token": + var hasToken bool + promptYN := &survey.Confirm{ + Message: "Do you have an access token?", + Default: false, } - } else { - promptI = &survey.Input{Message: "Username: "} - if err = survey.AskOne(promptI, &user, survey.WithValidator(survey.Required)); err != nil { + if err = survey.AskOne(promptYN, &hasToken); err != nil { return err } - promptPW := &survey.Password{Message: "Password: "} - if err = survey.AskOne(promptPW, &passwd, survey.WithValidator(survey.Required)); err != nil { + if hasToken { + promptI = &survey.Input{Message: "Token: "} + if err := survey.AskOne(promptI, &token, survey.WithValidator(survey.Required)); err != nil { + return err + } + } else { + promptI = &survey.Input{Message: "Username: "} + if err = survey.AskOne(promptI, &user, survey.WithValidator(survey.Required)); err != nil { + return err + } + + promptPW := &survey.Password{Message: "Password: "} + if err = survey.AskOne(promptPW, &passwd, survey.WithValidator(survey.Required)); err != nil { + return err + } + } + case "ssh-key/certificate": + promptI = &survey.Input{Message: "SSH Key/Certificate Path (leave empty for auto-discovery in ~/.ssh and ssh-agent):"} + if err := survey.AskOne(promptI, &sshKey); err != nil { return err } + + if sshKey == "" { + sshKey, err = promptSelect("Select ssh-key: ", task.ListSSHPubkey(), "", "") + if err != nil { + return err + } + + // ssh certificate + if strings.Contains(sshKey, "principals") { + sshCertPrincipal = regexp.MustCompile(`.*?principals: (.*?)[,|\s]`).FindStringSubmatch(sshKey)[1] + if strings.Contains(sshKey, "(ssh-agent)") { + sshAgent = true + sshKey = "" + } else { + sshKey = regexp.MustCompile(`\((.*?)\)$`).FindStringSubmatch(sshKey)[1] + sshKey = strings.TrimSuffix(sshKey, "-cert.pub") + } + } else { + sshKeyFingerprint = regexp.MustCompile(`(SHA256:.*?)\s`).FindStringSubmatch(sshKey)[1] + if strings.Contains(sshKey, "(ssh-agent)") { + sshAgent = true + sshKey = "" + } else { + sshKey = regexp.MustCompile(`\((.*?)\)$`).FindStringSubmatch(sshKey)[1] + sshKey = strings.TrimSuffix(sshKey, ".pub") + } + } + } } var optSettings bool - promptYN = &survey.Confirm{ + promptYN := &survey.Confirm{ Message: "Set Optional settings: ", Default: false, } @@ -87,5 +130,5 @@ func CreateLogin() error { } } - return task.CreateLogin(name, token, user, passwd, sshKey, giteaURL, insecure) + return task.CreateLogin(name, token, user, passwd, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint, insecure, sshAgent) } diff --git a/modules/task/login_create.go b/modules/task/login_create.go index 7d0e707..d042cfb 100644 --- a/modules/task/login_create.go +++ b/modules/task/login_create.go @@ -16,7 +16,7 @@ import ( ) // CreateLogin create a login to be stored in config -func CreateLogin(name, token, user, passwd, sshKey, giteaURL string, insecure bool) error { +func CreateLogin(name, token, user, passwd, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint string, insecure, sshAgent bool) error { // checks ... // ... if we have a url if len(giteaURL) == 0 { @@ -32,13 +32,15 @@ func CreateLogin(name, token, user, passwd, sshKey, giteaURL string, insecure bo return fmt.Errorf("token already been used, delete login '%s' first", login.Name) } - // .. if we have enough information to authenticate - if len(token) == 0 && (len(user)+len(passwd)) == 0 { - return fmt.Errorf("No token set") - } else if len(user) != 0 && len(passwd) == 0 { - return fmt.Errorf("No password set") - } else if len(user) == 0 && len(passwd) != 0 { - return fmt.Errorf("No user set") + if !sshAgent && sshCertPrincipal == "" && sshKey == "" { + // .. if we have enough information to authenticate + if len(token) == 0 && (len(user)+len(passwd)) == 0 { + return fmt.Errorf("No token set") + } else if len(user) != 0 && len(passwd) == 0 { + return fmt.Errorf("No password set") + } else if len(user) == 0 && len(passwd) != 0 { + return fmt.Errorf("No user set") + } } // Normalize URL @@ -47,16 +49,25 @@ func CreateLogin(name, token, user, passwd, sshKey, giteaURL string, insecure bo return fmt.Errorf("Unable to parse URL: %s", err) } - login := config.Login{ - Name: name, - URL: serverURL.String(), - Token: token, - Insecure: insecure, - SSHKey: sshKey, - Created: time.Now().Unix(), + // check if it's a certificate the principal doesn't matter as the user + // has explicitly selected this private key + if _, err := os.Stat(sshKey + "-cert.pub"); err == nil { + sshCertPrincipal = "yes" } - if len(token) == 0 { + login := config.Login{ + Name: name, + URL: serverURL.String(), + Token: token, + Insecure: insecure, + SSHKey: sshKey, + SSHCertPrincipal: sshCertPrincipal, + SSHKeyFingerprint: sshKeyFingerprint, + SSHAgent: sshAgent, + Created: time.Now().Unix(), + } + + if len(token) == 0 && sshCertPrincipal == "" && !sshAgent && sshKey == "" { if login.Token, err = generateToken(login, user, passwd); err != nil { return err } diff --git a/modules/task/login_httpsign.go b/modules/task/login_httpsign.go new file mode 100644 index 0000000..4b77c64 --- /dev/null +++ b/modules/task/login_httpsign.go @@ -0,0 +1,105 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package task + +import ( + "io/ioutil" + "path/filepath" + "strings" + + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/utils" + "golang.org/x/crypto/ssh" +) + +// ListSSHPubkey lists all the ssh keys in the ssh agent and the ~/.ssh/*.pub files +// It returns a list of SSH keys in the format of: +// "fingerprint keytype comment - principals: principals (ssh-agent or path to pubkey file)" +func ListSSHPubkey() []string { + var keys []string + + keys = append(keys, getAgentKeys()...) + keys = append(keys, getLocalKeys()...) + + return keys +} + +func getAgentKeys() []string { + ag, err := gitea.GetAgent() + if err != nil { + return []string{} + } + + akeys, err := ag.List() + if err != nil { + return nil + } + + var keys []string + + for _, akey := range akeys { + if key := parseKeys([]byte(akey.String()), "ssh-agent"); key != "" { + keys = append(keys, key) + } + } + + return keys +} + +func getLocalKeys() []string { + var keys []string + + // enumerate ~/.ssh/*.pub files + glob, err := utils.AbsPathWithExpansion("~/.ssh/*.pub") + if err != nil { + return []string{} + } + localPubkeyPaths, err := filepath.Glob(glob) + if err != nil { + return []string{} + } + + // parse each local key with present privkey & compare fingerprints to online keys + for _, pubkeyPath := range localPubkeyPaths { + var pubkeyFile []byte + pubkeyFile, err = ioutil.ReadFile(pubkeyPath) + if err != nil { + continue + } + + if key := parseKeys(pubkeyFile, pubkeyPath); key != "" { + keys = append(keys, key) + } + } + + return keys +} + +func parseKeys(pkinput []byte, sshPath string) string { + pkey, comment, _, _, err := ssh.ParseAuthorizedKey(pkinput) + if err != nil { + return "" + } + + if strings.Contains(pkey.Type(), "cert-v01@openssh.com") { + principals := pkey.(*ssh.Certificate).ValidPrincipals + return ssh.FingerprintSHA256(pkey) + " " + pkey.Type() + " " + comment + + " - principals: " + strings.Join(principals, ",") + " (" + sshPath + ")" + } + + return ssh.FingerprintSHA256(pkey) + " " + pkey.Type() + " " + comment + " (" + sshPath + ")" +} + +func getCertPrincipals(pkey ssh.PublicKey) []string { + var principals []string + + if cert, ok := pkey.(*ssh.Certificate); ok { + for _, principal := range cert.ValidPrincipals { + principals = append(principals, principal) + } + } + + return principals +} diff --git a/modules/utils/utils.go b/modules/utils/utils.go index 631c345..30c7e52 100644 --- a/modules/utils/utils.go +++ b/modules/utils/utils.go @@ -4,6 +4,12 @@ package utils +import ( + "os" + + "golang.org/x/crypto/ssh" +) + // Contains checks containment func Contains(haystack []string, needle string) bool { return IndexOf(haystack, needle) != -1 @@ -18,3 +24,20 @@ func IndexOf(haystack []string, needle string) int { } return -1 } + +// IsKeyEncrypted checks if the key is encrypted +func IsKeyEncrypted(sshKey string) (bool, error) { + priv, err := os.ReadFile(sshKey) + if err != nil { + return false, err + } + + _, err = ssh.ParsePrivateKey(priv) + if err != nil { + if _, ok := err.(*ssh.PassphraseMissingError); ok { + return true, nil + } + } + + return false, err +}