diff --git a/common/common_test.go b/common/common_test.go index 0028dcac..64ec0ccb 100644 --- a/common/common_test.go +++ b/common/common_test.go @@ -12,8 +12,10 @@ import ( "testing" "time" + "github.com/alexedwards/argon2id" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" "github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/kms" @@ -665,3 +667,43 @@ func TestCachedFs(t *testing.T) { err = os.Remove(user.HomeDir) assert.NoError(t, err) } + +func BenchmarkBcryptHashing(b *testing.B) { + bcryptPassword := "bcryptpassword" + for i := 0; i < b.N; i++ { + _, err := bcrypt.GenerateFromPassword([]byte(bcryptPassword), 10) + if err != nil { + panic(err) + } + } +} + +func BenchmarkCompareBcryptPassword(b *testing.B) { + bcryptPassword := "$2a$10$lPDdnDimJZ7d5/GwL6xDuOqoZVRXok6OHHhivCnanWUtcgN0Zafki" + for i := 0; i < b.N; i++ { + err := bcrypt.CompareHashAndPassword([]byte(bcryptPassword), []byte("password")) + if err != nil { + panic(err) + } + } +} + +func BenchmarkArgon2Hashing(b *testing.B) { + argonPassword := "argon2password" + for i := 0; i < b.N; i++ { + _, err := argon2id.CreateHash(argonPassword, argon2id.DefaultParams) + if err != nil { + panic(err) + } + } +} + +func BenchmarkCompareArgon2Password(b *testing.B) { + argon2Password := "$argon2id$v=19$m=65536,t=1,p=2$aOoAOdAwvzhOgi7wUFjXlw$wn/y37dBWdKHtPXHR03nNaKHWKPXyNuVXOknaU+YZ+s" + for i := 0; i < b.N; i++ { + _, err := argon2id.ComparePasswordAndHash("password", argon2Password) + if err != nil { + panic(err) + } + } +} diff --git a/common/protocol_test.go b/common/protocol_test.go index bdf44c97..d176913f 100644 --- a/common/protocol_test.go +++ b/common/protocol_test.go @@ -1987,13 +1987,13 @@ func TestUserPasswordHashing(t *testing.T) { err = config.LoadConfig(configDir, "") assert.NoError(t, err) providerConf := config.GetProviderConf() - providerConf.PasswordHashingAlgo = dataprovider.HashingAlgoBcrypt + providerConf.PasswordHashing.Algo = dataprovider.HashingAlgoArgon2ID err = dataprovider.Initialize(providerConf, configDir, true) assert.NoError(t, err) currentUser, err := dataprovider.UserExists(user.Username) assert.NoError(t, err) - assert.True(t, strings.HasPrefix(currentUser.Password, "$argon2id$")) + assert.True(t, strings.HasPrefix(currentUser.Password, "$2a$")) conn, client, err := getSftpClient(user) if assert.NoError(t, err) { @@ -2014,7 +2014,7 @@ func TestUserPasswordHashing(t *testing.T) { currentUser, err = dataprovider.UserExists(user.Username) assert.NoError(t, err) - assert.True(t, strings.HasPrefix(currentUser.Password, "$2a$")) + assert.True(t, strings.HasPrefix(currentUser.Password, "$argon2id$")) conn, client, err = getSftpClient(user) if assert.NoError(t, err) { diff --git a/config/config.go b/config/config.go index 88393aea..cc9fcd3f 100644 --- a/config/config.go +++ b/config/config.go @@ -223,8 +223,8 @@ func Init() { BcryptOptions: dataprovider.BcryptOptions{ Cost: 10, }, + Algo: dataprovider.HashingAlgoBcrypt, }, - PasswordHashingAlgo: dataprovider.HashingAlgoArgon2ID, PasswordCaching: true, UpdateMode: 0, PreferDatabaseCredentials: false, @@ -940,11 +940,11 @@ func setViperDefaults() { viper.SetDefault("data_provider.post_login_scope", globalConf.ProviderConf.PostLoginScope) viper.SetDefault("data_provider.check_password_hook", globalConf.ProviderConf.CheckPasswordHook) viper.SetDefault("data_provider.check_password_scope", globalConf.ProviderConf.CheckPasswordScope) + viper.SetDefault("data_provider.password_hashing.bcrypt_options.cost", globalConf.ProviderConf.PasswordHashing.BcryptOptions.Cost) viper.SetDefault("data_provider.password_hashing.argon2_options.memory", globalConf.ProviderConf.PasswordHashing.Argon2Options.Memory) viper.SetDefault("data_provider.password_hashing.argon2_options.iterations", globalConf.ProviderConf.PasswordHashing.Argon2Options.Iterations) viper.SetDefault("data_provider.password_hashing.argon2_options.parallelism", globalConf.ProviderConf.PasswordHashing.Argon2Options.Parallelism) - viper.SetDefault("data_provider.password_hashing.bcrypt_options.cost", globalConf.ProviderConf.PasswordHashing.BcryptOptions.Cost) - viper.SetDefault("data_provider.password_hashing_algo", globalConf.ProviderConf.PasswordHashingAlgo) + viper.SetDefault("data_provider.password_hashing.algo", globalConf.ProviderConf.PasswordHashing.Algo) viper.SetDefault("data_provider.update_mode", globalConf.ProviderConf.UpdateMode) viper.SetDefault("data_provider.skip_natural_keys_validation", globalConf.ProviderConf.SkipNaturalKeysValidation) viper.SetDefault("data_provider.delayed_quota_update", globalConf.ProviderConf.DelayedQuotaUpdate) diff --git a/dataprovider/admin.go b/dataprovider/admin.go index 3f9743df..eb5d5e75 100644 --- a/dataprovider/admin.go +++ b/dataprovider/admin.go @@ -66,7 +66,7 @@ type Admin struct { func (a *Admin) checkPassword() error { if a.Password != "" && !strings.HasPrefix(a.Password, argonPwdPrefix) { - if config.PasswordHashingAlgo == HashingAlgoBcrypt { + if config.PasswordHashing.Algo == HashingAlgoBcrypt { pwd, err := bcrypt.GenerateFromPassword([]byte(a.Password), config.PasswordHashing.BcryptOptions.Cost) if err != nil { return err diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 180aca10..0afcf562 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -158,8 +158,10 @@ type Argon2Options struct { // PasswordHashing defines the configuration for password hashing type PasswordHashing struct { - Argon2Options Argon2Options `json:"argon2_options" mapstructure:"argon2_options"` BcryptOptions BcryptOptions `json:"bcrypt_options" mapstructure:"bcrypt_options"` + Argon2Options Argon2Options `json:"argon2_options" mapstructure:"argon2_options"` + // Algorithm to use for hashing passwords. Available algorithms: argon2id, bcrypt. Default: bcrypt + Algo string `json:"algo" mapstructure:"algo"` } // UserActions defines the action to execute on user create, update, delete. @@ -287,8 +289,7 @@ type Config struct { // PreferDatabaseCredentials indicates whether credential files (currently used for Google // Cloud Storage) should be stored in the database instead of in the directory specified by // CredentialsPath. - PasswordHashingAlgo string `json:"password_hashing_algo" mapstructure:"password_hashing_algo"` - PreferDatabaseCredentials bool `json:"prefer_database_credentials" mapstructure:"prefer_database_credentials"` + PreferDatabaseCredentials bool `json:"prefer_database_credentials" mapstructure:"prefer_database_credentials"` // SkipNaturalKeysValidation allows to use any UTF-8 character for natural keys as username, admin name, // folder name. These keys are used in URIs for REST API and Web admin. By default only unreserved URI // characters are allowed: ALPHA / DIGIT / "-" / "." / "_" / "~". @@ -461,7 +462,7 @@ func Initialize(cnf Config, basePath string, checkAdmins bool) error { KeyLength: 32, } - if config.PasswordHashingAlgo == HashingAlgoBcrypt { + if config.PasswordHashing.Algo == HashingAlgoBcrypt { if config.PasswordHashing.BcryptOptions.Cost > bcrypt.MaxCost { err = fmt.Errorf("invalid bcrypt cost %v, max allowed %v", config.PasswordHashing.BcryptOptions.Cost, bcrypt.MaxCost) logger.WarnToConsole("Unable to initialize data provider: %v", err) @@ -1520,7 +1521,7 @@ func validateBaseParams(user *User) error { func createUserPasswordHash(user *User) error { if user.Password != "" && !user.IsPasswordHashed() { - if config.PasswordHashingAlgo == HashingAlgoBcrypt { + if config.PasswordHashing.Algo == HashingAlgoBcrypt { pwd, err := bcrypt.GenerateFromPassword([]byte(user.Password), config.PasswordHashing.BcryptOptions.Cost) if err != nil { return err diff --git a/dataprovider/user.go b/dataprovider/user.go index ed009afe..fab0bca2 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -174,8 +174,8 @@ type User struct { // 0 means no expiration ExpirationDate int64 `json:"expiration_date"` // Password used for password authentication. - // For users created using SFTPGo REST API the password is be stored using argon2id hashing algo. - // Checking passwords stored with bcrypt, pbkdf2, md5crypt and sha512crypt is supported too. + // For users created using SFTPGo REST API the password is be stored using bcrypt or argon2id hashing algo. + // Checking passwords stored with pbkdf2, md5crypt and sha512crypt is supported too. Password string `json:"password,omitempty"` // PublicKeys used for public key authentication. At least one between password and a public key is mandatory PublicKeys []string `json:"public_keys,omitempty"` diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 4eab070e..834975e2 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -194,15 +194,15 @@ The configuration file contains the following sections: - `post_login_scope`, defines the scope for the post-login hook. 0 means notify both failed and successful logins. 1 means notify failed logins. 2 means notify successful logins. - `check_password_hook`, string. Absolute path to an external program or an HTTP URL to invoke to check the user provided password. See [Check password hook](./check-password-hook.md) for more details. Leave empty to disable. - `check_password_scope`, defines the scope for the check password hook. 0 means all protocols, 1 means SSH, 2 means FTP, 4 means WebDAV. You can combine the scopes, for example 6 means FTP and WebDAV. - - `password_hashing`, struct. It contains the configuration parameters to be used to generate the password hash. SFTPGo can verify passwords in several formats and uses, by default, the `argon2id` algorithm to hash passwords in plain-text before storing them inside the data provider. These options allow you to customize how the hash is generated. + - `password_hashing`, struct. It contains the configuration parameters to be used to generate the password hash. SFTPGo can verify passwords in several formats and uses, by default, the `bcrypt` algorithm to hash passwords in plain-text before storing them inside the data provider. These options allow you to customize how the hash is generated. - `argon2_options`, struct containing the options for argon2id hashing algorithm. The `memory` and `iterations` parameters control the computational cost of hashing the password. The higher these figures are, the greater the cost of generating the hash and the longer the runtime. It also follows that the greater the cost will be for any attacker trying to guess the password. If the code is running on a machine with multiple cores, then you can decrease the runtime without reducing the cost by increasing the `parallelism` parameter. This controls the number of threads that the work is spread across. - `memory`, unsigned integer. The amount of memory used by the algorithm (in kibibytes). Default: 65536. - `iterations`, unsigned integer. The number of iterations over the memory. Default: 1. - `parallelism`. unsigned 8 bit integer. The number of threads (or lanes) used by the algorithm. Default: 2. - `bcrypt_options`, struct containing the options for bcrypt hashing algorithm - `cost`, integer between 4 and 31. Default: 10 - - `password_hashing_algo`, string. Algorithm to use for hashing passwords. Available algorithms: `argon2id`, `bcrypt`. Default: `argon2id` - - `password_caching`, boolean. Verifying argon2id passwords has a high memory and computational cost, verifying bcrypt passwords has a high CPU cost, by enabling, in memory, password caching you reduce these costs. Default: `true` + - `algo`, string. Algorithm to use for hashing passwords. Available algorithms: `argon2id`, `bcrypt`. For bcrypt hashing we use the `$s2a$` prefix. Default: `bcrypt` + - `password_caching`, boolean. Verifying argon2id passwords has a high memory and computational cost, verifying bcrypt passwords has a high computational cost, by enabling, in memory, password caching you reduce these costs. Default: `true` - `update_mode`, integer. Defines how the database will be initialized/updated. 0 means automatically. 1 means manually using the initprovider sub-command. - `skip_natural_keys_validation`, boolean. If `true` you can use any UTF-8 character for natural keys as username, admin name, folder name. These keys are used in URIs for REST API and Web admin. If `false` only unreserved URI characters are allowed: ALPHA / DIGIT / "-" / "." / "_" / "~". Default: `false`. - `delayed_quota_update`, integer. This configuration parameter defines the number of seconds to accumulate quota updates. If there are a lot of close uploads, accumulating quota updates can save you many queries to the data provider. If you want to track quotas, a scheduled quota update is recommended in any case, the stored quota size may be incorrect for several reasons, such as an unexpected shutdown, temporary provider failures, file copied outside of SFTPGo, and so on. 0 means immediate quota update. diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 02f9b031..65866633 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -291,8 +291,8 @@ func TestInitialization(t *testing.T) { } func TestBasicUserHandling(t *testing.T) { - user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) - assert.NoError(t, err) + user, resp, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err, string(resp)) user.MaxSessions = 10 user.QuotaSize = 4096 user.QuotaFiles = 2 @@ -383,13 +383,13 @@ func TestAdminPasswordHashing(t *testing.T) { err = config.LoadConfig(configDir, "") providerConf := config.GetProviderConf() assert.NoError(t, err) - providerConf.PasswordHashingAlgo = dataprovider.HashingAlgoBcrypt + providerConf.PasswordHashing.Algo = dataprovider.HashingAlgoArgon2ID err = dataprovider.Initialize(providerConf, configDir, true) assert.NoError(t, err) currentAdmin, err := dataprovider.AdminExists(defaultTokenAuthUser) assert.NoError(t, err) - assert.True(t, strings.HasPrefix(currentAdmin.Password, "$argon2id$")) + assert.True(t, strings.HasPrefix(currentAdmin.Password, "$2a$")) a := getTestAdmin() a.Username = altAdminUsername @@ -400,7 +400,7 @@ func TestAdminPasswordHashing(t *testing.T) { newAdmin, err := dataprovider.AdminExists(altAdminUsername) assert.NoError(t, err) - assert.True(t, strings.HasPrefix(newAdmin.Password, "$2a$")) + assert.True(t, strings.HasPrefix(newAdmin.Password, "$argon2id$")) token, _, err := httpdtest.GetToken(altAdminUsername, altAdminPassword) assert.NoError(t, err) diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index 600e12fc..b75c174a 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -1753,7 +1753,7 @@ components: password: type: string format: password - description: password or public key/SSH user certificate are mandatory. If the password has no known hashing algo prefix it will be stored, by default, 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, by default, using bcrypt, argon2id is supported too. You can send a password hashed as bcrypt ($s2a$ prefix), argon2id, pbkdf2 or unix crypt and it will be stored as is. For security reasons this field is omitted when you search/get users public_keys: type: array items: diff --git a/sftpgo.json b/sftpgo.json index 7be24d98..41844c41 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -161,16 +161,16 @@ "check_password_hook": "", "check_password_scope": 0, "password_hashing": { + "bcrypt_options": { + "cost": 10 + }, "argon2_options": { "memory": 65536, "iterations": 1, "parallelism": 2 }, - "bcrypt_options": { - "cost": 10 - } + "algo": "bcrypt" }, - "password_hashing_algo": "argon2id", "password_caching": true, "update_mode": 0, "skip_natural_keys_validation": false