From 738c7ab43e4b59b904968054c157e32a4b38166b Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Fri, 15 May 2020 20:08:53 +0200 Subject: [PATCH] sftpd: add support for SSH user certificate authentication This add support for PROTOCOL.certkeys vendor extension: https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?rev=1.8 Fixes #117 Signed-off-by: Nicola Murino --- README.md | 1 + config/config.go | 1 + dataprovider/dataprovider.go | 9 +- docs/full-configuration.md | 3 +- httpd/schema/openapi.yaml | 4 +- scripts/sftpgo_api_cli.py | 3 +- sftpd/internal_test.go | 39 ++++++++ sftpd/server.go | 128 ++++++++++++++++++++----- sftpd/sftpd_test.go | 180 ++++++++++++++++++++++++++++++----- sftpgo.json | 1 + templates/user.html | 2 +- utils/utils.go | 31 ++++++ 12 files changed, 346 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 72d43899..456861b0 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Fully featured and highly configurable SFTP server, written in Go - SFTP accounts are virtual accounts stored in a "data provider". - SQLite, MySQL, PostgreSQL, bbolt (key/value store in pure Go) and in-memory data providers are supported. - Public key and password authentication. Multiple public keys per user are supported. +- SSH user [certificate authentication](https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?rev=1.8). - Keyboard interactive authentication. You can easily setup a customizable multi-factor authentication. - Partial authentication. You can configure multi-step authentication requiring, for example, the user password after successful public key authentication. - Per user authentication methods. You can, for example, deny one or more authentication methods to one or more users. diff --git a/config/config.go b/config/config.go index 4a66bba6..6a6d9004 100644 --- a/config/config.go +++ b/config/config.go @@ -61,6 +61,7 @@ func init() { KexAlgorithms: []string{}, Ciphers: []string{}, MACs: []string{}, + TrustedUserCAKeys: []string{}, LoginBannerFile: "", EnabledSSHCommands: sftpd.GetDefaultSSHCommands(), KeyboardInteractiveHook: "", diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index b73a89f6..983f263a 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -956,8 +956,13 @@ func checkUserAndPubKey(user User, pubKey []byte) (User, string, error) { return user, "", err } if bytes.Equal(storedPubKey.Marshal(), pubKey) { - fp := ssh.FingerprintSHA256(storedPubKey) - return user, fp + ":" + comment, nil + certInfo := "" + cert, ok := storedPubKey.(*ssh.Certificate) + if ok { + certInfo = fmt.Sprintf(" %v ID: %v Serial: %v CA: %v", cert.Type(), cert.KeyId, cert.Serial, + ssh.FingerprintSHA256(cert.SignatureKey)) + } + return user, fmt.Sprintf("%v:%v%v", ssh.FingerprintSHA256(storedPubKey), comment, certInfo), nil } } return user, "", errors.New("Invalid credentials") diff --git a/docs/full-configuration.md b/docs/full-configuration.md index cc71cd98..802184fc 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -54,7 +54,8 @@ The configuration file contains the following sections: - `private_key`, path to the private key file. It can be a path relative to the config dir or an absolute one. - `kex_algorithms`, list of strings. Available KEX (Key Exchange) algorithms in preference order. Leave empty to use default values. The supported values can be found here: [`crypto/ssh`](https://github.com/golang/crypto/blob/master/ssh/common.go#L46 "Supported kex algos") - `ciphers`, list of strings. Allowed ciphers. Leave empty to use default values. The supported values can be found here: [`crypto/ssh`](https://github.com/golang/crypto/blob/master/ssh/common.go#L28 "Supported ciphers") - - `macs`, list of strings. available MAC (message authentication code) algorithms in preference order. Leave empty to use default values. The supported values can be found here: [`crypto/ssh`](https://github.com/golang/crypto/blob/master/ssh/common.go#L84 "Supported MACs") + - `macs`, list of strings. Available MAC (message authentication code) algorithms in preference order. Leave empty to use default values. The supported values can be found here: [`crypto/ssh`](https://github.com/golang/crypto/blob/master/ssh/common.go#L84 "Supported MACs") + - `trusted_user_ca_keys`, list of public keys paths of certificate authorities that are trusted to sign user certificates for authentication. The paths can be absolute or relative to the configuration directory. - `login_banner_file`, path to the login banner file. The contents of the specified file, if any, are sent to the remote user before authentication is allowed. It can be a path relative to the config dir or an absolute one. Leave empty to disable login banner. - `setstat_mode`, integer. 0 means "normal mode": requests for changing permissions, owner/group and access/modification times are executed. 1 means "ignore mode": requests for changing permissions, owner/group and access/modification times are silently ignored. - `enabled_ssh_commands`, list of enabled SSH commands. These SSH commands are enabled by default: `md5sum`, `sha1sum`, `cd`, `pwd`, `scp`. `*` enables all supported commands. Some commands are implemented directly inside SFTPGo, while for other commands we use system commands that need to be installed and in your system's `PATH`. For system commands we have no direct control on file creation/deletion and so we cannot support virtual folders, cloud storage filesystem, such as S3, and quota check is suboptimal: if quota is enabled, the number of files is checked at the command start and not while new files are created. The allowed size is calculated as the difference between the max quota and the used one, and it is checked against the bytes transferred via SSH. The command is aborted if it uploads more bytes than the remaining allowed size calculated at the command start. Anyway, we see the bytes that the remote command sends to the local command via SSH. These bytes contain both protocol commands and files, and so the size of the files is different from the size trasferred via SSH: for example, a command can send compressed files, or a protocol command (few bytes) could delete a big file. To mitigate this issue, quotas are recalculated at the command end with a full home directory scan. This could be heavy for big directories. If you need system commands and quotas you could consider disabling quota restrictions and periodically update quota usage yourself using the REST API. We support the following SSH commands: diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index d8a02e61..dcc2f7c4 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -1110,13 +1110,13 @@ components: password: type: string nullable: true - description: password or public key are mandatory. If the password has no known hashing algo prefix it will be stored using argon2id. You can send a password hashed as bcrypt or pbkdf2 and it will be stored as is. For security reasons this field is omitted when you search/get users + description: password or public key/SSH user certificate are mandatory. If the password has no known hashing algo prefix it will be stored using argon2id. You can send a password hashed as bcrypt or pbkdf2 and it will be stored as is. For security reasons this field is omitted when you search/get users public_keys: type: array items: type: string nullable: true - description: a password or at least one public key are mandatory. + description: a password or at least one public key/SSH user certificate are mandatory. home_dir: type: string description: path to the user home directory. The user cannot upload or download files outside this directory. SFTPGo tries to automatically create this folder if missing. Must be an absolute path diff --git a/scripts/sftpgo_api_cli.py b/scripts/sftpgo_api_cli.py index dc5522da..90e40fd8 100755 --- a/scripts/sftpgo_api_cli.py +++ b/scripts/sftpgo_api_cli.py @@ -496,7 +496,8 @@ def getDatetimeAsMillisSinceEpoch(dt): def addCommonUserArguments(parser): parser.add_argument('username', type=str) parser.add_argument('-P', '--password', type=str, default=None, help='Default: %(default)s') - parser.add_argument('-K', '--public-keys', type=str, nargs='+', default=[], help='Default: %(default)s') + parser.add_argument('-K', '--public-keys', type=str, nargs='+', default=[], help='Public keys or SSH user certificates. ' + + 'Default: %(default)s') parser.add_argument('-H', '--home-dir', type=str, default='', help='Default: %(default)s') parser.add_argument('--uid', type=int, default=0, help='Default: %(default)s') parser.add_argument('--gid', type=int, default=0, help='Default: %(default)s') diff --git a/sftpd/internal_test.go b/sftpd/internal_test.go index 122be70f..1ac5f2e7 100644 --- a/sftpd/internal_test.go +++ b/sftpd/internal_test.go @@ -17,6 +17,7 @@ import ( "github.com/eikenb/pipeat" "github.com/pkg/sftp" "github.com/stretchr/testify/assert" + "golang.org/x/crypto/ssh" "github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/utils" @@ -1734,3 +1735,41 @@ func TestProxyProtocolVersion(t *testing.T) { _, err = c.getProxyListener(nil) assert.Error(t, err) } + +func TestLoadHostKeys(t *testing.T) { + c := Configuration{} + c.Keys = []Key{ + { + PrivateKey: "missing file", + }, + } + err := c.checkAndLoadHostKeys("..", &ssh.ServerConfig{}) + assert.Error(t, err) + testfile := filepath.Join(os.TempDir(), "invalidkey") + err = ioutil.WriteFile(testfile, []byte("some bytes"), 0666) + assert.NoError(t, err) + c.Keys = []Key{ + { + PrivateKey: testfile, + }, + } + err = c.checkAndLoadHostKeys("..", &ssh.ServerConfig{}) + assert.Error(t, err) + err = os.Remove(testfile) + assert.NoError(t, err) +} + +func TestCertCheckerInitErrors(t *testing.T) { + c := Configuration{} + c.TrustedUserCAKeys = append(c.TrustedUserCAKeys, "missing file") + err := c.initializeCertChecker("") + assert.Error(t, err) + testfile := filepath.Join(os.TempDir(), "invalidkey") + err = ioutil.WriteFile(testfile, []byte("some bytes"), 0666) + assert.NoError(t, err) + c.TrustedUserCAKeys = []string{testfile} + err = c.initializeCertChecker("") + assert.Error(t, err) + err = os.Remove(testfile) + assert.NoError(t, err) +} diff --git a/sftpd/server.go b/sftpd/server.go index 26f04627..e2b94d88 100644 --- a/sftpd/server.go +++ b/sftpd/server.go @@ -1,6 +1,7 @@ package sftpd import ( + "bytes" "encoding/hex" "encoding/json" "fmt" @@ -24,8 +25,9 @@ import ( ) const ( - defaultPrivateRSAKeyName = "id_rsa" - defaultPrivateECDSAKeyName = "id_ecdsa" + defaultPrivateRSAKeyName = "id_rsa" + defaultPrivateECDSAKeyName = "id_ecdsa" + sourceAddressCriticalOption = "source-address" ) var ( @@ -71,6 +73,10 @@ type Configuration struct { // MACs Specifies the available MAC (message authentication code) algorithms // in preference order MACs []string `json:"macs" mapstructure:"macs"` + // TrustedUserCAKeys specifies a list of public keys paths of certificate authorities + // that are trusted to sign user certificates for authentication. + // The paths can be absolute or relative to the configuration directory + TrustedUserCAKeys []string `json:"trusted_user_ca_keys" mapstructure:"trusted_user_ca_keys"` // LoginBannerFile the contents of the specified file, if any, are sent to // the remote user before authentication is allowed. LoginBannerFile string `json:"login_banner_file" mapstructure:"login_banner_file"` @@ -119,12 +125,14 @@ type Configuration struct { // connection will be accepted and the header will be ignored. // If proxy protocol is set to 2 and we receive a proxy header from an IP that is not in the list then the // connection will be rejected. - ProxyAllowed []string `json:"proxy_allowed" mapstructure:"proxy_allowed"` + ProxyAllowed []string `json:"proxy_allowed" mapstructure:"proxy_allowed"` + certChecker *ssh.CertChecker + parsedUserCAKeys []ssh.PublicKey } // Key contains information about host keys type Key struct { - // The private key path relative to the configuration directory or absolute + // The private key path as absolute path or relative to the configuration directory PrivateKey string `json:"private_key" mapstructure:"private_key"` } @@ -157,7 +165,7 @@ func (c Configuration) Initialize(configDir string) error { return sp, nil }, PublicKeyCallback: func(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { - sp, err := c.validatePublicKeyCredentials(conn, pubKey.Marshal()) + sp, err := c.validatePublicKeyCredentials(conn, pubKey) if err == ssh.ErrPartialSuccess { return nil, err } @@ -178,8 +186,11 @@ func (c Configuration) Initialize(configDir string) error { ServerVersion: fmt.Sprintf("SSH-2.0-%v", c.Banner), } - err = c.checkAndLoadHostKeys(configDir, serverConfig) - if err != nil { + if err = c.checkAndLoadHostKeys(configDir, serverConfig); err != nil { + return err + } + + if err = c.initializeCertChecker(configDir); err != nil { return err } @@ -336,9 +347,9 @@ func (c Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Server var user dataprovider.User // Unmarshal cannot fails here and even if it fails we'll have a user with no permissions - json.Unmarshal([]byte(sconn.Permissions.Extensions["user"]), &user) //nolint:errcheck + json.Unmarshal([]byte(sconn.Permissions.Extensions["sftpgo_user"]), &user) //nolint:errcheck - loginType := sconn.Permissions.Extensions["login_method"] + loginType := sconn.Permissions.Extensions["sftpgo_login_method"] connectionID := hex.EncodeToString(sconn.SessionID()) fs, err := user.GetFilesystem(connectionID) @@ -474,8 +485,8 @@ func loginUser(user dataprovider.User, loginMethod, publicKey string, conn ssh.C } p := &ssh.Permissions{} p.Extensions = make(map[string]string) - p.Extensions["user"] = string(json) - p.Extensions["login_method"] = loginMethod + p.Extensions["sftpgo_user"] = string(json) + p.Extensions["sftpgo_login_method"] = loginMethod return p, nil } @@ -540,26 +551,93 @@ func (c *Configuration) checkAndLoadHostKeys(configDir string, serverConfig *ssh return nil } -func (c Configuration) validatePublicKeyCredentials(conn ssh.ConnMetadata, pubKey []byte) (*ssh.Permissions, error) { +func (c *Configuration) initializeCertChecker(configDir string) error { + for _, keyPath := range c.TrustedUserCAKeys { + if !filepath.IsAbs(keyPath) { + keyPath = filepath.Join(configDir, keyPath) + } + keyBytes, err := ioutil.ReadFile(keyPath) + if err != nil { + logger.Warn(logSender, "", "error loading trusted user CA key %#v: %v", keyPath, err) + logger.WarnToConsole("error loading trusted user CA key %#v: %v", keyPath, err) + return err + } + parsedKey, _, _, _, err := ssh.ParseAuthorizedKey(keyBytes) + if err != nil { + logger.Warn(logSender, "", "error parsing trusted user CA key %#v: %v", keyPath, err) + logger.WarnToConsole("error parsing trusted user CA key %#v: %v", keyPath, err) + return err + } + c.parsedUserCAKeys = append(c.parsedUserCAKeys, parsedKey) + } + c.certChecker = &ssh.CertChecker{ + SupportedCriticalOptions: []string{ + sourceAddressCriticalOption, + }, + IsUserAuthority: func(k ssh.PublicKey) bool { + for _, key := range c.parsedUserCAKeys { + if bytes.Equal(k.Marshal(), key.Marshal()) { + return true + } + } + return false + }, + } + return nil +} + +func (c Configuration) validatePublicKeyCredentials(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { var err error var user dataprovider.User var keyID string var sshPerm *ssh.Permissions + var certPerm *ssh.Permissions connectionID := hex.EncodeToString(conn.SessionID()) method := dataprovider.SSHLoginMethodPublicKey - if user, keyID, err = dataprovider.CheckUserAndPubKey(dataProvider, conn.User(), pubKey); err == nil { + cert, ok := pubKey.(*ssh.Certificate) + if ok { + if cert.CertType != ssh.UserCert { + err = fmt.Errorf("ssh: cert has type %d", cert.CertType) + updateLoginMetrics(conn, method, err) + return nil, err + } + if !c.certChecker.IsUserAuthority(cert.SignatureKey) { + err = fmt.Errorf("ssh: certificate signed by unrecognized authority") + updateLoginMetrics(conn, method, err) + return nil, err + } + if err := c.certChecker.CheckCert(conn.User(), cert); err != nil { + updateLoginMetrics(conn, method, err) + return nil, err + } + // we need to check source address ourself since crypto/ssh will skip this check if we return partial success + if cert.Permissions.CriticalOptions != nil && cert.Permissions.CriticalOptions[sourceAddressCriticalOption] != "" { + if err := utils.CheckSourceAddress(conn.RemoteAddr(), cert.Permissions.CriticalOptions[sourceAddressCriticalOption]); err != nil { + updateLoginMetrics(conn, method, err) + return nil, err + } + } + certPerm = &cert.Permissions + } + if user, keyID, err = dataprovider.CheckUserAndPubKey(dataProvider, conn.User(), pubKey.Marshal()); err == nil { if user.IsPartialAuth(method) { logger.Debug(logSender, connectionID, "user %#v authenticated with partial success", conn.User()) return nil, ssh.ErrPartialSuccess } sshPerm, err = loginUser(user, method, keyID, conn) + if err == nil && certPerm != nil { + // if we have a SSH user cert we need to merge certificate permissions with our ones + // we only set Extensions, so CriticalOptions are always the ones from the certificate + sshPerm.CriticalOptions = certPerm.CriticalOptions + if certPerm.Extensions != nil { + for k, v := range certPerm.Extensions { + sshPerm.Extensions[k] = v + } + } + } } - metrics.AddLoginAttempt(method) - if err != nil { - logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), method, err.Error()) - } - metrics.AddLoginResult(method, err) + updateLoginMetrics(conn, method, err) return sshPerm, err } @@ -572,14 +650,10 @@ func (c Configuration) validatePasswordCredentials(conn ssh.ConnMetadata, pass [ if len(conn.PartialSuccessMethods()) == 1 { method = dataprovider.SSHLoginMethodKeyAndPassword } - metrics.AddLoginAttempt(method) if user, err = dataprovider.CheckUserAndPass(dataProvider, conn.User(), string(pass)); err == nil { sshPerm, err = loginUser(user, method, "", conn) } - if err != nil { - logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), method, err.Error()) - } - metrics.AddLoginResult(method, err) + updateLoginMetrics(conn, method, err) return sshPerm, err } @@ -592,13 +666,17 @@ func (c Configuration) validateKeyboardInteractiveCredentials(conn ssh.ConnMetad if len(conn.PartialSuccessMethods()) == 1 { method = dataprovider.SSHLoginMethodKeyAndKeyboardInt } - metrics.AddLoginAttempt(method) if user, err = dataprovider.CheckKeyboardInteractiveAuth(dataProvider, conn.User(), c.KeyboardInteractiveHook, client); err == nil { sshPerm, err = loginUser(user, method, "", conn) } + updateLoginMetrics(conn, method, err) + return sshPerm, err +} + +func updateLoginMetrics(conn ssh.ConnMetadata, method string, err error) { + metrics.AddLoginAttempt(method) if err != nil { logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), method, err.Error()) } metrics.AddLoginResult(method, err) - return sshPerm, err } diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 89ab950d..c61fb7be 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -88,24 +88,39 @@ NbbCNsVroqKlChT5wyPNGS+phi2bPARBno7WSDvshTZ7dAVEP2c9MJW0XwoSevwKlhgSdt RLFFQ/5nclJSdzPBOmQouC0OBcMFSrYtMeknJ4VvueVvve5HcHFaEsaMc7ABAGaLYaBQOm iixITGvaNZh/tjAAAACW5pY29sYUBwMQE= -----END OPENSSH PRIVATE KEY-----` - configDir = ".." - permissionErrorString = "Permission Denied" - osWindows = "windows" + // test CA user key. + // % ssh-keygen -f ca_user_key + testCAUserKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDF5fcwZHiyixmnE6IlOZJpZhWXoh62gN+yadAA0GJ509SAEaZVLPDP8S5RsE8mUikR3wxynVshxHeqMhrkS+RlNbhSlOXDdNg94yTrq/xF8Z/PgKRInvef74k5i7bAIytza7jERzFJ/ujTEy3537T5k5EYQJ15ZQGuvzynSdv+6o99SjI4jFplyQOZ2QcYbEAmhHm5GgQlIiEFG/RlDtLksOulKZxOY3qPzP0AyQxtZJXn/5vG40aW9LTbwxCJqWlgrkFXMqAAVCbuU5YspwhiXmKt1PsldiXw23oloa4caCKN1jzbFiGuZNXEU2Ebx7JIvjQCPaUYwLjEbkRDxDqN/vmwZqBuKYiuG9Eafx+nFSQkr7QYb5b+mT+/1IFHnmeRGn38731kBqtH7tpzC/t+soRX9p2HtJM+9MYhblO2OqTSPGTlxihWUkyiRBekpAhaiHld16TsG+A3bOJHrojGcX+5g6oGarKGLAMcykL1X+rZqT993Mo6d2Z7q43MOXE= root@p1" + // this is testPubKey signed using testCAUserKey. + // % ssh-keygen -s ca_user_key -I test_user_sftp -n test_user_sftp -V always:forever -O source-address=127.0.0.1 -z 1 /tmp/test.pub + testCertValid = "ssh-rsa-cert-v01@openssh.com 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 nicola@p1" + // this is testPubKey signed using a CA user key different from testCAUserKey + testCertUntrustedCA = "ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAg8oFPWpjYy/DowMmtOjWj7Dq20d2N/4Rxzr/c710tOOUAAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0AAAAAAAAAAAAAAAEAAAAOdGVzdF91c2VyX3NmdHAAAAASAAAADnRlc3RfdXNlcl9zZnRwAAAAAAAAAAD//////////wAAAAAAAACCAAAAFXBlcm1pdC1YMTEtZm9yd2FyZGluZwAAAAAAAAAXcGVybWl0LWFnZW50LWZvcndhcmRpbmcAAAAAAAAAFnBlcm1pdC1wb3J0LWZvcndhcmRpbmcAAAAAAAAACnBlcm1pdC1wdHkAAAAAAAAADnBlcm1pdC11c2VyLXJjAAAAAAAAAAAAAAGXAAAAB3NzaC1yc2EAAAADAQABAAABgQCqgm2gVlptULThfpRR0oCb4SAU3368ULlJaiZOUdq6b94KTfgmu4hTLs7u3a8hyZnVxrKrJ93uAVCwa/HGtgiN96CNC6JUt/QnPqTJ8LQ207RdoE9fbOe6mGwOle5z45+5JFoIi5ZZuD8JsBGodVoa92UepoMyBcNtZyl9q2GP4yT2tIYRon79dtG9AXiDYyhSgePqaObN67dn3ivMc4ZGNukK3cG07cYPic5y0wxX16wSMG3pGQDyUkAu+s4AqpnV9EWHM4PE7SYkCXE99++tUK3QALYqvGZKrLHgzmDKi6n+e14vHYUppAeGDZzwlawiY4oGP9eOW2KUfjZe2ZeL22JTFDYzH2lNV2WtUpeKRGGTSGaUblRVC9hRt6hKCT4c7qpW4kO4kPhE39JpcNPGLql7srNkw+3xXBs8xghMPtH3nOl1Rz2mxnX5tAqmPBb+KiPepnrs+pBRu7i+nCVp8az+iN87STYHy+zPtvTR+QURC8BpNraPOfXwpwM2HaMAAAGUAAAADHJzYS1zaGEyLTUxMgAAAYBnTXCL6tXUO3/Gtsm7lnH9Sulzca8FOoI4Y/4bVYhq4iUNu7Ca452m+Xr9qmCEoIyIJF0LEEcJ8jcS4rfX15e7tNNoknv7JbYXBFAbp1Y/76iqVf89FjfVcbEyH2ToAf7eyQAWzQ3gEKS8mQIkLnAwmCboUXC4GRodSIiOXiTt5Q6T02MVc8TxkhmlTA0uVLd5XgstySgE/oLBnL59lhJcwQmdhHL+m480+PaW55CtMuC36RTwk/tOyuWCDC5qMXnoveNB3yu45o3L/U4hoyJ0/5FyP5C8ahgydY0LoRZQG/mNzuraY4433rK+IfkQvZTyaDtcjhxE6hCD5F40aDDh88i6XaKAPikD6fqra6BN8PoPgLuRHzOJuqsMXBWM99s7qPgSnBbmXlekz/1jvvFiCh3zvAFTxFz2KyE4+SbDcCrhpxkNL7idw6r/ZsHaI/2+zhDcgSs5MgBwYLJEj6zUqVdp5XsF8YfC7yNZV5/qy68qY2+zXrC57SPifU2SCPE= nicola@p1" + // this is testPubKey signed as host certificate. + // % ssh-keygen -s ca_user_key -I test_user_sftp -h -n test_user_sftp -V always:forever -z 2 /tmp/test.pub + testHostCert = "ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAg7O2LDpLO1jGTX3SSzEMILoAYJb9DdggyyaUMXUUg3L4AAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0AAAAAAAAAAgAAAAIAAAAOdGVzdF91c2VyX3NmdHAAAAASAAAADnRlc3RfdXNlcl9zZnRwAAAAAAAAAAD//////////wAAAAAAAAAAAAAAAAAAAZcAAAAHc3NoLXJzYQAAAAMBAAEAAAGBAMXl9zBkeLKLGacToiU5kmlmFZeiHraA37Jp0ADQYnnT1IARplUs8M/xLlGwTyZSKRHfDHKdWyHEd6oyGuRL5GU1uFKU5cN02D3jJOur/EXxn8+ApEie95/viTmLtsAjK3NruMRHMUn+6NMTLfnftPmTkRhAnXllAa6/PKdJ2/7qj31KMjiMWmXJA5nZBxhsQCaEebkaBCUiIQUb9GUO0uSw66UpnE5jeo/M/QDJDG1klef/m8bjRpb0tNvDEImpaWCuQVcyoABUJu5TliynCGJeYq3U+yV2JfDbeiWhrhxoIo3WPNsWIa5k1cRTYRvHski+NAI9pRjAuMRuREPEOo3++bBmoG4piK4b0Rp/H6cVJCSvtBhvlv6ZP7/UgUeeZ5EaffzvfWQGq0fu2nML+36yhFf2nYe0kz70xiFuU7Y6pNI8ZOXGKFZSTKJEF6SkCFqIeV3XpOwb4Dds4keuiMZxf7mDqgZqsoYsAxzKQvVf6tmpP33cyjp3Znurjcw5cQAAAZQAAAAMcnNhLXNoYTItNTEyAAABgHlAWMTTzNrE6pxHlkr09ZXsHgJi8U2p7eifs56DOLgklYIXVUJPEEcnzMKGdpPBnqJsvg3+PccqxgOr5L1dFuOmekQ/dGiHd1enrESiGvJOvDfm0WsuBjxEZkSNFWgC9Z2NltToMmRlhVBmb4ZRZtAmi9DAFlJ/BDV4t8ikXZ5oUsigwIeOeLkdPFx3C3x9KZIpuwuAIV4Nfmz75q1NMWY2K1hv682QCKwMYqOWSotz1vWunNmZ0yPRl9UwqAq+nqwO3AApnlrQ3MmHujWQ5tl65PyhfpI8oghhUtB6YrJIAuRXNI/S0+KewCpiYm7nbFBtv9lpecujxAeTibYBrFZ5VODEUm3sdQ/HMdTmkhi6xNgPDQVlvKFqBJAaqoO3tbhKTbEZ865tJMqhyxmZ4XY08wduvSVobrNr7s3rm42/FXLIpung+UOVXonHyeIv9zQ0iJ/bvqKQ1fOsTisZdcD0lz80ZGsjdgJt7yKfUNBnAyVbTXm048E3WsZslJIYCA== nicola@p1" + // this is testPubKey signed using testCAUserKey but with source address 172.16.34.45. + // % ssh-keygen -s ca_user_key -I test_user_sftp -n test_user_sftp -V always:forever -O source-address=172.16.34.45 -z 3 /tmp/test.pub + testCertOtherSourceAddress = "ssh-rsa-cert-v01@openssh.com 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 nicola@p1" + configDir = ".." + permissionErrorString = "Permission Denied" + osWindows = "windows" ) var ( - allPerms = []string{dataprovider.PermAny} - homeBasePath string - scpPath string - gitPath string - sshPath string - pubKeyPath string - privateKeyPath string - gitWrapPath string - extAuthPath string - keyIntAuthPath string - preLoginPath string - logFilePath string + allPerms = []string{dataprovider.PermAny} + homeBasePath string + scpPath string + gitPath string + sshPath string + pubKeyPath string + privateKeyPath string + trustedCAUserKey string + gitWrapPath string + extAuthPath string + keyIntAuthPath string + preLoginPath string + logFilePath string ) func TestMain(m *testing.M) { @@ -178,6 +193,7 @@ func TestMain(m *testing.M) { pubKeyPath = filepath.Join(homeBasePath, "ssh_key.pub") privateKeyPath = filepath.Join(homeBasePath, "ssh_key") + trustedCAUserKey = filepath.Join(homeBasePath, "ca_user_key") gitWrapPath = filepath.Join(homeBasePath, "gitwrap.sh") extAuthPath = filepath.Join(homeBasePath, "extauth.sh") preLoginPath = filepath.Join(homeBasePath, "prelogin.sh") @@ -194,6 +210,11 @@ func TestMain(m *testing.M) { if err != nil { logger.WarnToConsole("unable to save gitwrap shell script: %v", err) } + err = ioutil.WriteFile(trustedCAUserKey, []byte(testCAUserKey), 0600) + if err != nil { + logger.WarnToConsole("unable to save trusted CA user key: %v", err) + } + sftpdConf.TrustedUserCAKeys = append(sftpdConf.TrustedUserCAKeys, trustedCAUserKey) sftpd.SetDataProvider(dataProvider) httpd.SetDataProvider(dataProvider) @@ -240,6 +261,7 @@ func TestMain(m *testing.M) { os.Remove(loginBannerFile) os.Remove(pubKeyPath) os.Remove(privateKeyPath) + os.Remove(trustedCAUserKey) os.Remove(gitWrapPath) os.Remove(extAuthPath) os.Remove(preLoginPath) @@ -751,6 +773,63 @@ func TestLogin(t *testing.T) { assert.NoError(t, err) } +func TestLoginUserCert(t *testing.T) { + u := getTestUser(true) + u.PublicKeys = []string{testCertValid, testCertUntrustedCA, testHostCert, testCertOtherSourceAddress} + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + // try login using a cert signed from a trusted CA + signer, err := getSignerForUserCert([]byte(testCertValid)) + assert.NoError(t, err) + client, err := getCustomAuthSftpClient(user, []ssh.AuthMethod{ssh.PublicKeys(signer)}) + if assert.NoError(t, err) { + defer client.Close() + assert.NoError(t, checkBasicSFTP(client)) + } + // try login using a cert signed from an untrusted CA + signer, err = getSignerForUserCert([]byte(testCertUntrustedCA)) + assert.NoError(t, err) + client, err = getCustomAuthSftpClient(user, []ssh.AuthMethod{ssh.PublicKeys(signer)}) + if !assert.Error(t, err) { + client.Close() + } + // try login using an host certificate instead of an user certificate + signer, err = getSignerForUserCert([]byte(testHostCert)) + assert.NoError(t, err) + client, err = getCustomAuthSftpClient(user, []ssh.AuthMethod{ssh.PublicKeys(signer)}) + if !assert.Error(t, err) { + client.Close() + } + // try login using a user certificate with an authorized source address different from localhost + signer, err = getSignerForUserCert([]byte(testCertOtherSourceAddress)) + assert.NoError(t, err) + client, err = getCustomAuthSftpClient(user, []ssh.AuthMethod{ssh.PublicKeys(signer)}) + if !assert.Error(t, err) { + client.Close() + } + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + // now login with a username not in the set of valid principals for given certificate + u.Username += "1" + user, _, err = httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + + signer, err = getSignerForUserCert([]byte(testCertValid)) + assert.NoError(t, err) + client, err = getCustomAuthSftpClient(user, []ssh.AuthMethod{ssh.PublicKeys(signer)}) + if !assert.Error(t, err) { + client.Close() + } + + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestMultiStepLoginKeyAndPwd(t *testing.T) { u := getTestUser(true) u.Password = defaultPassword @@ -770,9 +849,9 @@ func TestMultiStepLoginKeyAndPwd(t *testing.T) { if !assert.Error(t, err, "login with password is disallowed and must fail") { client.Close() } - key, _ := ssh.ParsePrivateKey([]byte(testPrivateKey)) + signer, _ := ssh.ParsePrivateKey([]byte(testPrivateKey)) authMethods := []ssh.AuthMethod{ - ssh.PublicKeys(key), + ssh.PublicKeys(signer), ssh.Password(defaultPassword), } client, err = getCustomAuthSftpClient(user, authMethods) @@ -782,7 +861,7 @@ func TestMultiStepLoginKeyAndPwd(t *testing.T) { } authMethods = []ssh.AuthMethod{ ssh.Password(defaultPassword), - ssh.PublicKeys(key), + ssh.PublicKeys(signer), } _, err = getCustomAuthSftpClient(user, authMethods) assert.Error(t, err, "multi step auth login with wrong order must fail") @@ -813,9 +892,9 @@ func TestMultiStepLoginKeyAndKeyInt(t *testing.T) { client.Close() } - key, _ := ssh.ParsePrivateKey([]byte(testPrivateKey)) + signer, _ := ssh.ParsePrivateKey([]byte(testPrivateKey)) authMethods := []ssh.AuthMethod{ - ssh.PublicKeys(key), + ssh.PublicKeys(signer), ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) { return []string{"1", "2"}, nil }), @@ -829,13 +908,13 @@ func TestMultiStepLoginKeyAndKeyInt(t *testing.T) { ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) { return []string{"1", "2"}, nil }), - ssh.PublicKeys(key), + ssh.PublicKeys(signer), } _, err = getCustomAuthSftpClient(user, authMethods) assert.Error(t, err, "multi step auth login with wrong order must fail") authMethods = []ssh.AuthMethod{ - ssh.PublicKeys(key), + ssh.PublicKeys(signer), ssh.Password(defaultPassword), } _, err = getCustomAuthSftpClient(user, authMethods) @@ -847,6 +926,47 @@ func TestMultiStepLoginKeyAndKeyInt(t *testing.T) { assert.NoError(t, err) } +func TestMultiStepLoginCertAndPwd(t *testing.T) { + u := getTestUser(true) + u.Password = defaultPassword + u.PublicKeys = []string{testCertValid, testCertOtherSourceAddress} + u.Filters.DeniedLoginMethods = append(u.Filters.DeniedLoginMethods, []string{ + dataprovider.SSHLoginMethodKeyAndKeyboardInt, + dataprovider.SSHLoginMethodPublicKey, + dataprovider.SSHLoginMethodPassword, + dataprovider.SSHLoginMethodKeyboardInteractive, + }...) + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + signer, err := getSignerForUserCert([]byte(testCertValid)) + assert.NoError(t, err) + authMethods := []ssh.AuthMethod{ + ssh.PublicKeys(signer), + ssh.Password(defaultPassword), + } + client, err := getCustomAuthSftpClient(user, authMethods) + if assert.NoError(t, err) { + defer client.Close() + assert.NoError(t, checkBasicSFTP(client)) + } + + signer, err = getSignerForUserCert([]byte(testCertOtherSourceAddress)) + assert.NoError(t, err) + authMethods = []ssh.AuthMethod{ + ssh.PublicKeys(signer), + ssh.Password(defaultPassword), + } + client, err = getCustomAuthSftpClient(user, authMethods) + if !assert.Error(t, err) { + client.Close() + } + + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestLoginUserStatus(t *testing.T) { usePubKey := true user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK) @@ -3949,6 +4069,18 @@ func runSSHCommand(command string, user dataprovider.User, usePubKey bool) ([]by return stdout.Bytes(), err } +func getSignerForUserCert(certBytes []byte) (ssh.Signer, error) { + signer, err := ssh.ParsePrivateKey([]byte(testPrivateKey)) + if err != nil { + return nil, err + } + cert, _, _, _, err := ssh.ParseAuthorizedKey(certBytes) //nolint:dogsled + if err != nil { + return nil, err + } + return ssh.NewCertSigner(cert.(*ssh.Certificate), signer) +} + func getSftpClientWithAddr(user dataprovider.User, usePubKey bool, addr string) (*sftp.Client, error) { var sftpClient *sftp.Client config := &ssh.ClientConfig{ @@ -3958,11 +4090,11 @@ func getSftpClientWithAddr(user dataprovider.User, usePubKey bool, addr string) }, } if usePubKey { - key, err := ssh.ParsePrivateKey([]byte(testPrivateKey)) + signer, err := ssh.ParsePrivateKey([]byte(testPrivateKey)) if err != nil { return nil, err } - config.Auth = []ssh.AuthMethod{ssh.PublicKeys(key)} + config.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)} } else { if len(user.Password) > 0 { config.Auth = []ssh.AuthMethod{ssh.Password(user.Password)} diff --git a/sftpgo.json b/sftpgo.json index fc31c36b..e18550e7 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -16,6 +16,7 @@ "kex_algorithms": [], "ciphers": [], "macs": [], + "trusted_user_ca_keys":[], "login_banner_file": "", "setstat_mode": 0, "enabled_ssh_commands": [ diff --git a/templates/user.html b/templates/user.html index bcab79d6..2a8927b5 100644 --- a/templates/user.html +++ b/templates/user.html @@ -65,7 +65,7 @@ - One public key per line + One public key or SSH user certificate per line diff --git a/utils/utils.go b/utils/utils.go index bd8bee7b..1580ebb9 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -317,3 +317,34 @@ func CleanDirInput(dirInput string) string { } return filepath.Clean(dirInput) } + +// CheckSourceAddress check the source address against the one defined inside an SSH user certificate +func CheckSourceAddress(addr net.Addr, sourceAddrs string) error { + if addr == nil { + return errors.New("ssh: no address known for client, but source-address match required") + } + + tcpAddr, ok := addr.(*net.TCPAddr) + if !ok { + return fmt.Errorf("ssh: remote address %v is not an TCP address when checking source-address match", addr) + } + + for _, sourceAddr := range strings.Split(sourceAddrs, ",") { + if allowedIP := net.ParseIP(sourceAddr); allowedIP != nil { + if allowedIP.Equal(tcpAddr.IP) { + return nil + } + } else { + _, ipNet, err := net.ParseCIDR(sourceAddr) + if err != nil { + return fmt.Errorf("ssh: error parsing source-address restriction %q: %v", sourceAddr, err) + } + + if ipNet.Contains(tcpAddr.IP) { + return nil + } + } + } + + return fmt.Errorf("ssh: remote address %v is not allowed because of source-address restriction", addr) +}