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 <wim@42.be>
Reviewed-on: https://gitea.com/gitea/tea/pulls/442
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: 6543 <6543@obermui.de>
Co-authored-by: Wim <42wim@noreply.gitea.io>
Co-committed-by: Wim <42wim@noreply.gitea.io>
This commit is contained in:
Wim 2022-09-15 03:00:08 +08:00 committed by 6543
parent 4ee5ce4b52
commit 6a4ba6a689
6 changed files with 262 additions and 40 deletions

View File

@ -53,13 +53,23 @@ var CmdLoginAdd = cli.Command{
&cli.StringFlag{ &cli.StringFlag{
Name: "ssh-key", Name: "ssh-key",
Aliases: []string{"s"}, 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{ &cli.BoolFlag{
Name: "insecure", Name: "insecure",
Aliases: []string{"i"}, Aliases: []string{"i"},
Usage: "Disable TLS verification", 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, Action: runLoginAdd,
} }
@ -70,6 +80,11 @@ func runLoginAdd(ctx *cli.Context) error {
return interact.CreateLogin() return interact.CreateLogin()
} }
sshAgent := false
if ctx.String("ssh-agent-key") != "" || ctx.String("ssh-agent-principal") != "" {
sshAgent = true
}
// else use args to add login // else use args to add login
return task.CreateLogin( return task.CreateLogin(
ctx.String("name"), ctx.String("name"),
@ -78,5 +93,8 @@ func runLoginAdd(ctx *cli.Context) error {
ctx.String("password"), ctx.String("password"),
ctx.String("ssh-key"), ctx.String("ssh-key"),
ctx.String("url"), ctx.String("url"),
ctx.Bool("insecure")) ctx.String("ssh-agent-principal"),
ctx.String("ssh-agent-key"),
ctx.Bool("insecure"),
sshAgent)
} }

View File

@ -15,6 +15,8 @@ import (
"strings" "strings"
"code.gitea.io/sdk/gitea" "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 // 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"` Default bool `yaml:"default"`
SSHHost string `yaml:"ssh_host"` SSHHost string `yaml:"ssh_host"`
// optional path to the private key // optional path to the private key
SSHKey string `yaml:"ssh_key"` SSHKey string `yaml:"ssh_key"`
Insecure bool `yaml:"insecure"` 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 is username from gitea
User string `yaml:"user"` User string `yaml:"user"`
// Created is auto created unix timestamp // Created is auto created unix timestamp
@ -132,7 +138,7 @@ func GetLoginByHost(host string) *Login {
// DeleteLogin delete a login by name from config // DeleteLogin delete a login by name from config
func DeleteLogin(name string) error { func DeleteLogin(name string) error {
var idx = -1 idx := -1
for i, l := range config.Logins { for i, l := range config.Logins {
if l.Name == name { if l.Name == name {
idx = i idx = i
@ -172,11 +178,27 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
Jar: cookieJar, Jar: cookieJar,
Transport: &http.Transport{ Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}} },
}
} }
options = append(options, gitea.SetToken(l.Token), gitea.SetHTTPClient(httpClient)) 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...) client, err := gitea.NewClient(l.URL, options...)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)

View File

@ -6,6 +6,7 @@ package interact
import ( import (
"fmt" "fmt"
"regexp"
"strings" "strings"
"code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/task"
@ -15,8 +16,9 @@ import (
// CreateLogin create an login interactive // CreateLogin create an login interactive
func CreateLogin() error { 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 insecure = false
var sshAgent = false
promptI := &survey.Input{Message: "URL of Gitea instance: "} promptI := &survey.Input{Message: "URL of Gitea instance: "}
if err := survey.AskOne(promptI, &giteaURL, survey.WithValidator(survey.Required)); err != nil { if err := survey.AskOne(promptI, &giteaURL, survey.WithValidator(survey.Required)); err != nil {
@ -38,34 +40,75 @@ func CreateLogin() error {
return err return err
} }
var hasToken bool loginMethod, err := promptSelect("Login with: ", []string{"token", "ssh-key/certificate"}, "", "")
promptYN := &survey.Confirm{ if err != nil {
Message: "Do you have an access token?",
Default: false,
}
if err = survey.AskOne(promptYN, &hasToken); err != nil {
return err return err
} }
if hasToken { switch loginMethod {
promptI = &survey.Input{Message: "Token: "} case "token":
if err := survey.AskOne(promptI, &token, survey.WithValidator(survey.Required)); err != nil { var hasToken bool
return err promptYN := &survey.Confirm{
Message: "Do you have an access token?",
Default: false,
} }
} else { if err = survey.AskOne(promptYN, &hasToken); err != nil {
promptI = &survey.Input{Message: "Username: "}
if err = survey.AskOne(promptI, &user, survey.WithValidator(survey.Required)); err != nil {
return err return err
} }
promptPW := &survey.Password{Message: "Password: "} if hasToken {
if err = survey.AskOne(promptPW, &passwd, survey.WithValidator(survey.Required)); err != nil { 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 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 var optSettings bool
promptYN = &survey.Confirm{ promptYN := &survey.Confirm{
Message: "Set Optional settings: ", Message: "Set Optional settings: ",
Default: false, 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)
} }

View File

@ -16,7 +16,7 @@ import (
) )
// CreateLogin create a login to be stored in config // 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 ... // checks ...
// ... if we have a url // ... if we have a url
if len(giteaURL) == 0 { 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) return fmt.Errorf("token already been used, delete login '%s' first", login.Name)
} }
// .. if we have enough information to authenticate if !sshAgent && sshCertPrincipal == "" && sshKey == "" {
if len(token) == 0 && (len(user)+len(passwd)) == 0 { // .. if we have enough information to authenticate
return fmt.Errorf("No token set") if len(token) == 0 && (len(user)+len(passwd)) == 0 {
} else if len(user) != 0 && len(passwd) == 0 { return fmt.Errorf("No token set")
return fmt.Errorf("No password set") } else if len(user) != 0 && len(passwd) == 0 {
} else if len(user) == 0 && len(passwd) != 0 { return fmt.Errorf("No password set")
return fmt.Errorf("No user set") } else if len(user) == 0 && len(passwd) != 0 {
return fmt.Errorf("No user set")
}
} }
// Normalize URL // 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) return fmt.Errorf("Unable to parse URL: %s", err)
} }
login := config.Login{ // check if it's a certificate the principal doesn't matter as the user
Name: name, // has explicitly selected this private key
URL: serverURL.String(), if _, err := os.Stat(sshKey + "-cert.pub"); err == nil {
Token: token, sshCertPrincipal = "yes"
Insecure: insecure,
SSHKey: sshKey,
Created: time.Now().Unix(),
} }
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 { if login.Token, err = generateToken(login, user, passwd); err != nil {
return err return err
} }

View File

@ -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
}

View File

@ -4,6 +4,12 @@
package utils package utils
import (
"os"
"golang.org/x/crypto/ssh"
)
// Contains checks containment // Contains checks containment
func Contains(haystack []string, needle string) bool { func Contains(haystack []string, needle string) bool {
return IndexOf(haystack, needle) != -1 return IndexOf(haystack, needle) != -1
@ -18,3 +24,20 @@ func IndexOf(haystack []string, needle string) int {
} }
return -1 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
}