Browse Source

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 <nicola.murino@gmail.com>
Nicola Murino 5 years ago
parent
commit
738c7ab43e

+ 1 - 0
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".
 - 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.
 - 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.
 - 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.
 - 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.
 - 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.
 - Per user authentication methods. You can, for example, deny one or more authentication methods to one or more users.

+ 1 - 0
config/config.go

@@ -61,6 +61,7 @@ func init() {
 			KexAlgorithms:           []string{},
 			KexAlgorithms:           []string{},
 			Ciphers:                 []string{},
 			Ciphers:                 []string{},
 			MACs:                    []string{},
 			MACs:                    []string{},
+			TrustedUserCAKeys:       []string{},
 			LoginBannerFile:         "",
 			LoginBannerFile:         "",
 			EnabledSSHCommands:      sftpd.GetDefaultSSHCommands(),
 			EnabledSSHCommands:      sftpd.GetDefaultSSHCommands(),
 			KeyboardInteractiveHook: "",
 			KeyboardInteractiveHook: "",

+ 7 - 2
dataprovider/dataprovider.go

@@ -956,8 +956,13 @@ func checkUserAndPubKey(user User, pubKey []byte) (User, string, error) {
 			return user, "", err
 			return user, "", err
 		}
 		}
 		if bytes.Equal(storedPubKey.Marshal(), pubKey) {
 		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")
 	return user, "", errors.New("Invalid credentials")

+ 2 - 1
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.
     - `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")
   - `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")
   - `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.
   - `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.
   - `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:
   - `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:

+ 2 - 2
httpd/schema/openapi.yaml

@@ -1110,13 +1110,13 @@ components:
         password:
         password:
           type: string
           type: string
           nullable: true
           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:
         public_keys:
           type: array
           type: array
           items:
           items:
             type: string
             type: string
           nullable: true
           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:
         home_dir:
           type: string
           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
           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

+ 2 - 1
scripts/sftpgo_api_cli.py

@@ -496,7 +496,8 @@ def getDatetimeAsMillisSinceEpoch(dt):
 def addCommonUserArguments(parser):
 def addCommonUserArguments(parser):
 	parser.add_argument('username', type=str)
 	parser.add_argument('username', type=str)
 	parser.add_argument('-P', '--password', type=str, default=None, help='Default: %(default)s')
 	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('-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('--uid', type=int, default=0, help='Default: %(default)s')
 	parser.add_argument('--gid', type=int, default=0, help='Default: %(default)s')
 	parser.add_argument('--gid', type=int, default=0, help='Default: %(default)s')

+ 39 - 0
sftpd/internal_test.go

@@ -17,6 +17,7 @@ import (
 	"github.com/eikenb/pipeat"
 	"github.com/eikenb/pipeat"
 	"github.com/pkg/sftp"
 	"github.com/pkg/sftp"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
+	"golang.org/x/crypto/ssh"
 
 
 	"github.com/drakkan/sftpgo/dataprovider"
 	"github.com/drakkan/sftpgo/dataprovider"
 	"github.com/drakkan/sftpgo/utils"
 	"github.com/drakkan/sftpgo/utils"
@@ -1734,3 +1735,41 @@ func TestProxyProtocolVersion(t *testing.T) {
 	_, err = c.getProxyListener(nil)
 	_, err = c.getProxyListener(nil)
 	assert.Error(t, err)
 	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)
+}

+ 103 - 25
sftpd/server.go

@@ -1,6 +1,7 @@
 package sftpd
 package sftpd
 
 
 import (
 import (
+	"bytes"
 	"encoding/hex"
 	"encoding/hex"
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
@@ -24,8 +25,9 @@ import (
 )
 )
 
 
 const (
 const (
-	defaultPrivateRSAKeyName   = "id_rsa"
-	defaultPrivateECDSAKeyName = "id_ecdsa"
+	defaultPrivateRSAKeyName    = "id_rsa"
+	defaultPrivateECDSAKeyName  = "id_ecdsa"
+	sourceAddressCriticalOption = "source-address"
 )
 )
 
 
 var (
 var (
@@ -71,6 +73,10 @@ type Configuration struct {
 	// MACs Specifies the available MAC (message authentication code) algorithms
 	// MACs Specifies the available MAC (message authentication code) algorithms
 	// in preference order
 	// in preference order
 	MACs []string `json:"macs" mapstructure:"macs"`
 	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
 	// LoginBannerFile the contents of the specified file, if any, are sent to
 	// the remote user before authentication is allowed.
 	// the remote user before authentication is allowed.
 	LoginBannerFile string `json:"login_banner_file" mapstructure:"login_banner_file"`
 	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.
 	// 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
 	// 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.
 	// 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
 // Key contains information about host keys
 type Key struct {
 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"`
 	PrivateKey string `json:"private_key" mapstructure:"private_key"`
 }
 }
 
 
@@ -157,7 +165,7 @@ func (c Configuration) Initialize(configDir string) error {
 			return sp, nil
 			return sp, nil
 		},
 		},
 		PublicKeyCallback: func(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) {
 		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 {
 			if err == ssh.ErrPartialSuccess {
 				return nil, err
 				return nil, err
 			}
 			}
@@ -178,8 +186,11 @@ func (c Configuration) Initialize(configDir string) error {
 		ServerVersion: fmt.Sprintf("SSH-2.0-%v", c.Banner),
 		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
 		return err
 	}
 	}
 
 
@@ -336,9 +347,9 @@ func (c Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Server
 	var user dataprovider.User
 	var user dataprovider.User
 
 
 	// Unmarshal cannot fails here and even if it fails we'll have a user with no permissions
 	// 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())
 	connectionID := hex.EncodeToString(sconn.SessionID())
 
 
 	fs, err := user.GetFilesystem(connectionID)
 	fs, err := user.GetFilesystem(connectionID)
@@ -474,8 +485,8 @@ func loginUser(user dataprovider.User, loginMethod, publicKey string, conn ssh.C
 	}
 	}
 	p := &ssh.Permissions{}
 	p := &ssh.Permissions{}
 	p.Extensions = make(map[string]string)
 	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
 	return p, nil
 }
 }
 
 
@@ -540,26 +551,93 @@ func (c *Configuration) checkAndLoadHostKeys(configDir string, serverConfig *ssh
 	return nil
 	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 err error
 	var user dataprovider.User
 	var user dataprovider.User
 	var keyID string
 	var keyID string
 	var sshPerm *ssh.Permissions
 	var sshPerm *ssh.Permissions
+	var certPerm *ssh.Permissions
 
 
 	connectionID := hex.EncodeToString(conn.SessionID())
 	connectionID := hex.EncodeToString(conn.SessionID())
 	method := dataprovider.SSHLoginMethodPublicKey
 	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) {
 		if user.IsPartialAuth(method) {
 			logger.Debug(logSender, connectionID, "user %#v authenticated with partial success", conn.User())
 			logger.Debug(logSender, connectionID, "user %#v authenticated with partial success", conn.User())
 			return nil, ssh.ErrPartialSuccess
 			return nil, ssh.ErrPartialSuccess
 		}
 		}
 		sshPerm, err = loginUser(user, method, keyID, conn)
 		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
 	return sshPerm, err
 }
 }
 
 
@@ -572,14 +650,10 @@ func (c Configuration) validatePasswordCredentials(conn ssh.ConnMetadata, pass [
 	if len(conn.PartialSuccessMethods()) == 1 {
 	if len(conn.PartialSuccessMethods()) == 1 {
 		method = dataprovider.SSHLoginMethodKeyAndPassword
 		method = dataprovider.SSHLoginMethodKeyAndPassword
 	}
 	}
-	metrics.AddLoginAttempt(method)
 	if user, err = dataprovider.CheckUserAndPass(dataProvider, conn.User(), string(pass)); err == nil {
 	if user, err = dataprovider.CheckUserAndPass(dataProvider, conn.User(), string(pass)); err == nil {
 		sshPerm, err = loginUser(user, method, "", conn)
 		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
 	return sshPerm, err
 }
 }
 
 
@@ -592,13 +666,17 @@ func (c Configuration) validateKeyboardInteractiveCredentials(conn ssh.ConnMetad
 	if len(conn.PartialSuccessMethods()) == 1 {
 	if len(conn.PartialSuccessMethods()) == 1 {
 		method = dataprovider.SSHLoginMethodKeyAndKeyboardInt
 		method = dataprovider.SSHLoginMethodKeyAndKeyboardInt
 	}
 	}
-	metrics.AddLoginAttempt(method)
 	if user, err = dataprovider.CheckKeyboardInteractiveAuth(dataProvider, conn.User(), c.KeyboardInteractiveHook, client); err == nil {
 	if user, err = dataprovider.CheckKeyboardInteractiveAuth(dataProvider, conn.User(), c.KeyboardInteractiveHook, client); err == nil {
 		sshPerm, err = loginUser(user, method, "", conn)
 		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 {
 	if err != nil {
 		logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), method, err.Error())
 		logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), method, err.Error())
 	}
 	}
 	metrics.AddLoginResult(method, err)
 	metrics.AddLoginResult(method, err)
-	return sshPerm, err
 }
 }

File diff suppressed because it is too large
+ 5 - 3
sftpd/sftpd_test.go


+ 1 - 0
sftpgo.json

@@ -16,6 +16,7 @@
     "kex_algorithms": [],
     "kex_algorithms": [],
     "ciphers": [],
     "ciphers": [],
     "macs": [],
     "macs": [],
+    "trusted_user_ca_keys":[],
     "login_banner_file": "",
     "login_banner_file": "",
     "setstat_mode": 0,
     "setstat_mode": 0,
     "enabled_ssh_commands": [
     "enabled_ssh_commands": [

+ 1 - 1
templates/user.html

@@ -65,7 +65,7 @@
             <textarea class="form-control" id="idPublicKeys" name="public_keys" rows="3"
             <textarea class="form-control" id="idPublicKeys" name="public_keys" rows="3"
                 aria-describedby="pkHelpBlock">{{range .User.PublicKeys}}{{.}}&#10;{{end}}</textarea>
                 aria-describedby="pkHelpBlock">{{range .User.PublicKeys}}{{.}}&#10;{{end}}</textarea>
             <small id="pkHelpBlock" class="form-text text-muted">
             <small id="pkHelpBlock" class="form-text text-muted">
-                One public key per line
+                One public key or SSH user certificate per line
             </small>
             </small>
         </div>
         </div>
     </div>
     </div>

+ 31 - 0
utils/utils.go

@@ -317,3 +317,34 @@ func CleanDirInput(dirInput string) string {
 	}
 	}
 	return filepath.Clean(dirInput)
 	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)
+}

Some files were not shown because too many files changed in this diff