diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 576414d193571..40d44fb0aa848 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -379,6 +379,37 @@ INTERNAL_TOKEN= ;; Validate against https://haveibeenpwned.com/Passwords to see if a password has been exposed ;PASSWORD_CHECK_PWN = false +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +[security.password_hash] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; +;; Every parameter for the built in hash algorithms can be changed in this section. +;; Handle with care! Changing the values can massively change the hash calculation time and/or memory needed for the hashing process. +;; After changing a value, the user password hash will be recalculated on next successful login. +;; +;; In order to use values diverging from the defaults, all parameters (for the hash of choice) need to be set! +;; +;; Parameters for BCrypt +;BCRYPT_COST = 10 +;; +;; Parameters for scrypt (all 4 parameters need to be set) +;SCRYPT_N = 65536 +;SCRYPT_R = 16 +;SCRYPT_P = 2 +;SCRYPT_KEY_LENGTH = 50 +;; +;; Parameters for Argon2id (all 4 parameters need to be set) +;ARGON2_ITERATIONS = 2 +;ARGON2_MEMORY = 65536 +;ARGON2_PARALLELISM = 8 +;ARGON2_KEY_LENGTH = 50 +;; +;; Parameters for Pbkdf2 (both parameters need to be set) +;PBKDF2_ITERATIONS = 10000 +;PBKDF2_KEY_LENGTH = 50 + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; [oauth2] diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index 274c97543a25b..1f4b7ebe891b1 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -441,6 +441,20 @@ relation to port exhaustion. - off - do not check password complexity - `PASSWORD_CHECK_PWN`: **false**: Check [HaveIBeenPwned](https://haveibeenpwned.com/Passwords) to see if a password has been exposed. +## Password Hash Algo (`password_hash`) + +- `BCRYPT_COST`: **10**: The `cost` parameter for Bcrypt hashing. Values from 4 to 31 are allowed. [More info on the bcrypt hashing algorithm.](https://en.wikipedia.org/wiki/Bcrypt#Algorithm) +- `SCRYPT_N`: **65536**: The `CostFactor N` for Scrypt hashing. Must be a power of two. [More info on the scrypt hashing algorithm.](https://en.wikipedia.org/wiki/Scrypt#Algorithm) +- `SCRYPT_R`: **16**: The `BlockSizeFactor r` for Scrypt hashing. +- `SCRYPT_P`: **2**: The `ParallelizationFactor p` for Scrypt hashing. +- `SCRYPT_KEY_LENGTH`: **50**: Desired key length for Scrypt hashing. +- `ARGON2_ITERATIONS`: **2**: Number of iterations for Argon2id hashing. [More info on the Argon2 hashing algorithm.](https://en.wikipedia.org/wiki/Argon2#Algorithm) +- `ARGON2_MEMORY`: **65536**: Amount of memory to use for Argon2id hashing. +- `ARGON2_PARALLELISM`: **8**: Number of threads to use for Argon2id hashing. +- `ARGON2_KEY_LENGTH`: **50**: Desired key length for Argon2id hashing. +- `PBKDF2_ITERATIONS`: **10000**: Number of iterations for PBKDF2 hashing. [More info on the PBKDF2 hashing algorithm.](https://en.wikipedia.org/wiki/PBKDF2) +- `PBKDF2_KEY_LENGTH`: **50**: Desired key length for PBKDF2 hashing. + ## OpenID (`openid`) - `ENABLE_OPENID_SIGNIN`: **false**: Allow authentication in via OpenID. diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index 850ee4041d81a..da1d616249501 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -529,15 +529,34 @@ id: 31 lower_name: user31 name: user31 - full_name: "user31" + full_name: User ThirtyOne email: user31@example.com - passwd_hash_algo: argon2 - passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b # password + passwd_hash_algo: argon2$2$65536$12$50 + passwd: 1d2304e48d92db2fed37bdb4b3e7fca97fc41f2454b32de343007f0acb4623f04b07a477759fa83df487d12310a2c4c1ab25 # password type: 0 # individual salt: ZogKvWdyEx is_admin: false - visibility: 2 - avatar: avatar31 + login_type: 1 + is_restricted: true + avatar: avatar29 avatar_email: user31@example.com num_repos: 0 is_active: true + +- + id: 32 + lower_name: user32 + name: user32 + full_name: User ThirtyTwo + email: user32@example.com + passwd_hash_algo: argon2$2$65536$12$50 + passwd: 1d2304e48d92db2fed37bdb4b3e7fca97fc41f2454b32de343007f0acb4623f04b07a477759fa83df487d12310a2c4c1ab25 # password + type: 0 # individual + salt: ZogKvWdyEx + is_admin: false + login_type: 1 + is_restricted: true + avatar: avatar29 + avatar_email: user32@example.com + num_repos: 0 + is_active: true diff --git a/models/login_source.go b/models/login_source.go index bbd605bb41d7d..1975f75b08b9c 100644 --- a/models/login_source.go +++ b/models/login_source.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" + "code.gitea.io/gitea/modules/auth/hash" "code.gitea.io/gitea/modules/auth/ldap" "code.gitea.io/gitea/modules/auth/oauth2" "code.gitea.io/gitea/modules/auth/pam" @@ -814,7 +815,7 @@ func UserSignIn(username, password string) (*User, error) { if user.IsPasswordSet() && user.ValidatePassword(password) { // Update password hash if server password hash algorithm have changed - if user.PasswdHashAlgo != setting.PasswordHashAlgo { + if hash.DefaultHasher.PasswordNeedUpdate(user.PasswdHashAlgo) { if err = user.SetPassword(password); err != nil { return nil, err } diff --git a/models/user.go b/models/user.go index f606da53d65f0..e7ff6ad794075 100644 --- a/models/user.go +++ b/models/user.go @@ -8,8 +8,6 @@ package models import ( "container/list" "context" - "crypto/sha256" - "crypto/subtle" "encoding/hex" "errors" "fmt" @@ -21,6 +19,7 @@ import ( "time" "unicode/utf8" + "code.gitea.io/gitea/modules/auth/hash" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" @@ -30,10 +29,6 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" - "golang.org/x/crypto/argon2" - "golang.org/x/crypto/bcrypt" - "golang.org/x/crypto/pbkdf2" - "golang.org/x/crypto/scrypt" "golang.org/x/crypto/ssh" "xorm.io/builder" ) @@ -375,56 +370,28 @@ func (u *User) NewGitSig() *git.Signature { } } -func hashPassword(passwd, salt, algo string) string { - var tempPasswd []byte - - switch algo { - case algoBcrypt: - tempPasswd, _ = bcrypt.GenerateFromPassword([]byte(passwd), bcrypt.DefaultCost) - return string(tempPasswd) - case algoScrypt: - tempPasswd, _ = scrypt.Key([]byte(passwd), []byte(salt), 65536, 16, 2, 50) - case algoArgon2: - tempPasswd = argon2.IDKey([]byte(passwd), []byte(salt), 2, 65536, 8, 50) - case algoPbkdf2: - fallthrough - default: - tempPasswd = pbkdf2.Key([]byte(passwd), []byte(salt), 10000, 50, sha256.New) - } - - return fmt.Sprintf("%x", tempPasswd) -} - // SetPassword hashes a password using the algorithm defined in the config value of PASSWORD_HASH_ALGO // change passwd, salt and passwd_hash_algo fields func (u *User) SetPassword(passwd string) (err error) { if len(passwd) == 0 { u.Passwd = "" - u.Salt = "" u.PasswdHashAlgo = "" + u.Salt = "" return nil } if u.Salt, err = GetUserSalt(); err != nil { return err } - u.PasswdHashAlgo = setting.PasswordHashAlgo - u.Passwd = hashPassword(passwd, u.Salt, setting.PasswordHashAlgo) - return nil + u.Passwd, u.PasswdHashAlgo, err = hash.DefaultHasher.HashPassword(passwd, u.Salt, "") + + return err } -// ValidatePassword checks if given password matches the one belongs to the user. +// ValidatePassword checks if given password matches the one belonging to the user. func (u *User) ValidatePassword(passwd string) bool { - tempHash := hashPassword(passwd, u.Salt, u.PasswdHashAlgo) - - if u.PasswdHashAlgo != algoBcrypt && subtle.ConstantTimeCompare([]byte(u.Passwd), []byte(tempHash)) == 1 { - return true - } - if u.PasswdHashAlgo == algoBcrypt && bcrypt.CompareHashAndPassword([]byte(u.Passwd), []byte(passwd)) == nil { - return true - } - return false + return hash.DefaultHasher.Validate(passwd, u.Passwd, u.Salt, u.PasswdHashAlgo) } // IsPasswordSet checks if the password is set or left empty diff --git a/models/user_test.go b/models/user_test.go index 34c465c586498..1719506a50026 100644 --- a/models/user_test.go +++ b/models/user_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "code.gitea.io/gitea/modules/auth/hash" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" @@ -137,13 +138,13 @@ func TestSearchUsers(t *testing.T) { } testUserSuccess(&SearchUserOptions{OrderBy: "id ASC", ListOptions: ListOptions{Page: 1}}, - []int64{1, 2, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30}) + []int64{1, 2, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32}) testUserSuccess(&SearchUserOptions{ListOptions: ListOptions{Page: 1}, IsActive: util.OptionalBoolFalse}, []int64{9}) testUserSuccess(&SearchUserOptions{OrderBy: "id ASC", ListOptions: ListOptions{Page: 1}, IsActive: util.OptionalBoolTrue}, - []int64{1, 2, 4, 5, 8, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 28, 29, 30}) + []int64{1, 2, 4, 5, 8, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 28, 29, 30, 32}) testUserSuccess(&SearchUserOptions{Keyword: "user1", OrderBy: "id ASC", ListOptions: ListOptions{Page: 1}, IsActive: util.OptionalBoolTrue}, []int64{1, 10, 11, 12, 13, 14, 15, 16, 18}) @@ -225,7 +226,7 @@ func TestHashPasswordDeterministic(t *testing.T) { u := &User{} algos := []string{"argon2", "pbkdf2", "scrypt", "bcrypt"} for j := 0; j < len(algos); j++ { - u.PasswdHashAlgo = algos[j] + hash.DefaultHasher.DefaultAlgorithm = algos[j] for i := 0; i < 50; i++ { // generate a random password rand.Read(b) @@ -234,17 +235,59 @@ func TestHashPasswordDeterministic(t *testing.T) { // save the current password in the user - hash it and store the result u.SetPassword(pass) r1 := u.Passwd + a1 := u.PasswdHashAlgo // run again u.SetPassword(pass) r2 := u.Passwd + a2 := u.PasswdHashAlgo assert.NotEqual(t, r1, r2) + assert.NotEqual(t, a2, algos[j]) + assert.Equal(t, a1, a2) assert.True(t, u.ValidatePassword(pass)) } } } +func TestOldPasswordMatchAndUpdate(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + u := AssertExistsAndLoadBean(t, &User{ID: 32}).(*User) + + hash.DefaultHasher.DefaultAlgorithm = "argon2" + + matchingPass := "password" + oldPass := u.Passwd + oldAlgo := u.PasswdHashAlgo + + validates := u.ValidatePassword(matchingPass) + newPass := u.Passwd + // Should match even with not matching current config + assert.True(t, validates) + // Should not be altered + assert.Equal(t, oldPass, newPass) + + // With update function + argonHasher := hash.DefaultHasher.Hashers["argon2"].(*hash.Argon2Hasher) + argonHasher.Iterations = 2 + argonHasher.Memory = 65536 + argonHasher.Parallelism = 8 + argonHasher.KeyLength = 50 + + user, _ := UserSignIn("user32", matchingPass) + + validates = user.ValidatePassword(matchingPass) + newPass = user.Passwd + newAlgo := user.PasswdHashAlgo + + // Should still match after config update + assert.True(t, validates) + // Should be updated to new config + assert.NotEqual(t, oldPass, newPass) + // Should not be equal - test users Parallelism is not matching + assert.NotEqual(t, oldAlgo, newAlgo) +} + func BenchmarkHashPassword(b *testing.B) { // BenchmarkHashPassword ensures that it takes a reasonable amount of time // to hash a password - in order to protect from brute-force attacks. diff --git a/modules/auth/hash/argon2.go b/modules/auth/hash/argon2.go new file mode 100644 index 0000000000000..5b18ceaf1df4a --- /dev/null +++ b/modules/auth/hash/argon2.go @@ -0,0 +1,92 @@ +// Copyright 2021 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 hash + +import ( + "crypto/subtle" + "fmt" + "strconv" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Argon2Hasher is a Hash implementation for Argon2 +type Argon2Hasher struct { + Iterations uint32 `ini:"ARGON2_ITERATIONS"` + Memory uint32 `ini:"ARGON2_MEMORY"` + Parallelism uint8 `ini:"ARGON2_PARALLELISM"` + KeyLength uint32 `ini:"ARGON2_KEY_LENGTH"` + fallback string +} + +// HashPassword returns a PasswordHash, PassWordAlgo (and optionally an error) +func (h *Argon2Hasher) HashPassword(password, salt, config string) (string, string, error) { + var tempPasswd []byte + if config == "fallback" { + // Fixed default config to match with original configuration + config = h.fallback + } + + split := strings.Split(config, "$") + if len(split) != 4 { + fmt.Printf("Take from Config: %v", h.getConfigFromSetting()) + split = strings.Split(h.getConfigFromSetting(), "$") + } + + var iterations, memory, keyLength uint32 + var parallelism uint8 + var tmp int + + var err error + + if tmp, err = strconv.Atoi(split[0]); err != nil { + return "", "", err + } + iterations = uint32(tmp) + + if tmp, err = strconv.Atoi(split[1]); err != nil { + return "", "", err + } + memory = uint32(tmp) + if tmp, err = strconv.Atoi(split[2]); err != nil { + return "", "", err + } + parallelism = uint8(tmp) + if tmp, err = strconv.Atoi(split[3]); err != nil { + return "", "", err + } + keyLength = uint32(tmp) + + tempPasswd = argon2.IDKey([]byte(password), []byte(salt), iterations, memory, parallelism, keyLength) + return fmt.Sprintf("%x", tempPasswd), + fmt.Sprintf("argon2$%d$%d$%d$%d", iterations, memory, parallelism, keyLength), + nil +} + +// Validate validates a plain-text password +func (h *Argon2Hasher) Validate(password, hash, salt, config string) bool { + tempHash, _, _ := h.HashPassword(password, salt, config) + return subtle.ConstantTimeCompare([]byte(hash), []byte(tempHash)) == 1 +} + +func (h *Argon2Hasher) getConfigFromAlgo(algo string) string { + split := strings.SplitN(algo, "$", 2) + if len(split) == 1 { + split[1] = "fallback" + } + return split[1] +} + +func (h *Argon2Hasher) getConfigFromSetting() string { + if h.Iterations == 0 || h.Memory == 0 || h.Parallelism == 0 || h.KeyLength == 0 { + return h.fallback + } + return fmt.Sprintf("%d$%d$%d$%d", h.Iterations, h.Memory, h.Parallelism, h.KeyLength) +} + +func init() { + DefaultHasher.Hashers["argon2"] = &Argon2Hasher{2, 65536, 8, 50, "2$65536$8$50"} +} diff --git a/modules/auth/hash/bcrypt.go b/modules/auth/hash/bcrypt.go new file mode 100644 index 0000000000000..b1b2e7450dd35 --- /dev/null +++ b/modules/auth/hash/bcrypt.go @@ -0,0 +1,58 @@ +// Copyright 2021 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 hash + +import ( + "fmt" + "strconv" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +// BCryptHasher is a Hash implementation for BCrypt +type BCryptHasher struct { + Cost int `ini:"BCRYPT_COST"` + fallback string +} + +// HashPassword returns a PasswordHash, PassWordAlgo (and optionally an error) +func (h *BCryptHasher) HashPassword(password, salt, config string) (string, string, error) { + if config == "fallback" { + // Fixed default config to match with original configuration + config = h.fallback + } else if config == "" { + config = h.getConfigFromSetting() + } + + cost, err := strconv.Atoi(config) + if err == nil { + var tempPasswd []byte + tempPasswd, _ = bcrypt.GenerateFromPassword([]byte(password), cost) + return string(tempPasswd), fmt.Sprintf("bcrypt$%d", cost), nil + } + return "", "", err +} + +// Validate validates a plain-text password +func (h *BCryptHasher) Validate(password, hash, salt, config string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +func (h *BCryptHasher) getConfigFromAlgo(algo string) string { + split := strings.SplitN(algo, "$", 2) + return split[1] +} + +func (h *BCryptHasher) getConfigFromSetting() string { + if h.Cost == 0 { + return h.fallback + } + return strconv.Itoa(h.Cost) +} + +func init() { + DefaultHasher.Hashers["bcrypt"] = &BCryptHasher{10, "10"} +} diff --git a/modules/auth/hash/hash.go b/modules/auth/hash/hash.go new file mode 100644 index 0000000000000..8e13c67e3a997 --- /dev/null +++ b/modules/auth/hash/hash.go @@ -0,0 +1,88 @@ +// Copyright 2021 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 hash + +import ( + "strings" +) + +const defaultAlgorithm = "pbkdf2" + +// DefaultHasherStruct stores the available hashing instances +type DefaultHasherStruct struct { + // DefaultAlgorithm is the default hashing algorithm + DefaultAlgorithm string + // Hashers is a map of algorithm name to Hasher implementation + // We use a map as it is easier to use + Hashers map[string]Hasher +} + +// Hasher is the interface for a single hash implementation +type Hasher interface { + Validate(password, hash, salt, config string) bool + HashPassword(password, salt, config string) (string, string, error) + getConfigFromSetting() string + getConfigFromAlgo(algo string) string +} + +// HashPassword returns a PasswordHash, PassWordAlgo (and optionally an error) +func (d *DefaultHasherStruct) HashPassword(password, salt, config string) (string, string, error) { + hasher, ok := d.Hashers[d.DefaultAlgorithm] + if !ok { + hasher = d.Hashers[defaultAlgorithm] + } + + return hasher.HashPassword(password, salt, config) +} + +// Validate validates a plain-text password +func (d *DefaultHasherStruct) Validate(password, hash, salt, algo string) bool { + var typ, config string + var hasher Hasher + var ok bool + split := strings.SplitN(algo, "$", 2) + if len(split) == 1 { + typ = split[0] + config = "fallback" + } else { + typ, config = split[0], split[1] + } + + if len(config) == 0 || len(typ) == 0 { + return false + } + + if hasher, ok = d.Hashers[typ]; ok { + return hasher.Validate(password, hash, salt, config) + } + return false +} + +// PasswordNeedUpdate determines if a password needs an update +func (d *DefaultHasherStruct) PasswordNeedUpdate(algo string) bool { + var typ, tail string + var hasher Hasher + var ok bool + split := strings.SplitN(algo, "$", 2) + if len(split) == 1 { + return true + } + typ, tail = split[0], split[1] + + if len(tail) == 0 || len(typ) == 0 || typ != d.DefaultAlgorithm { + return true + } + + if hasher, ok = d.Hashers[typ]; ok { + return hasher.getConfigFromAlgo(algo) != hasher.getConfigFromSetting() + } + return true +} + +// DefaultHasher is the instance of the HashSet +var DefaultHasher = &DefaultHasherStruct{ + DefaultAlgorithm: defaultAlgorithm, + Hashers: make(map[string]Hasher), +} diff --git a/modules/auth/hash/pbkdf2.go b/modules/auth/hash/pbkdf2.go new file mode 100644 index 0000000000000..5e49488451b9e --- /dev/null +++ b/modules/auth/hash/pbkdf2.go @@ -0,0 +1,76 @@ +// Copyright 2021 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 hash + +import ( + "crypto/sha256" + "crypto/subtle" + "fmt" + "strconv" + "strings" + + "golang.org/x/crypto/pbkdf2" +) + +// Pbkdf2Hasher is a Hash implementation for Pbkdf2 +type Pbkdf2Hasher struct { + Iterations int `ini:"PBKDF2_ITERATIONS"` + KeyLength int `ini:"PBKDF2_KEY_LENGTH"` + fallback string +} + +// HashPassword returns a PasswordHash, PassWordAlgo (and optionally an error) +func (h *Pbkdf2Hasher) HashPassword(password, salt, config string) (string, string, error) { + var tempPasswd []byte + if config == "fallback" { + // Fixed default config to match with original configuration + config = h.fallback + } + + split := strings.Split(config, "$") + if len(split) != 2 { + split = strings.Split(h.getConfigFromSetting(), "$") + } + + var iterations, parallelism int + var err error + + if iterations, err = strconv.Atoi(split[0]); err != nil { + return "", "", err + } + if parallelism, err = strconv.Atoi(split[1]); err != nil { + return "", "", err + } + + tempPasswd = pbkdf2.Key([]byte(password), []byte(salt), iterations, parallelism, sha256.New) + return fmt.Sprintf("%x", tempPasswd), + fmt.Sprintf("pbkdf2$%d$%d", iterations, parallelism), + nil +} + +// Validate validates a plain-text password +func (h *Pbkdf2Hasher) Validate(password, hash, salt, config string) bool { + tempHash, _, _ := h.HashPassword(password, salt, config) + return subtle.ConstantTimeCompare([]byte(hash), []byte(tempHash)) == 1 +} + +func (h *Pbkdf2Hasher) getConfigFromAlgo(algo string) string { + split := strings.SplitN(algo, "$", 2) + if len(split) == 1 { + split[1] = "fallback" + } + return split[1] +} + +func (h *Pbkdf2Hasher) getConfigFromSetting() string { + if h.Iterations == 0 || h.KeyLength == 0 { + return h.fallback + } + return fmt.Sprintf("%d$%d", h.Iterations, h.KeyLength) +} + +func init() { + DefaultHasher.Hashers["pbkdf2"] = &Pbkdf2Hasher{10000, 50, "10000$50"} +} diff --git a/modules/auth/hash/scrypt.go b/modules/auth/hash/scrypt.go new file mode 100644 index 0000000000000..7986ec3282c12 --- /dev/null +++ b/modules/auth/hash/scrypt.go @@ -0,0 +1,83 @@ +// Copyright 2021 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 hash + +import ( + "crypto/subtle" + "fmt" + "strconv" + "strings" + + "golang.org/x/crypto/scrypt" +) + +// SCryptHasher is a Hash implementation for SCrypt +type SCryptHasher struct { + N int `ini:"SCRYPT_N"` + R int `ini:"SCRYPT_R"` + P int `ini:"SCRYPT_P"` + KeyLength int `ini:"SCRYPT_KEY_LENGTH"` + fallback string +} + +// HashPassword returns a PasswordHash, PassWordAlgo (and optionally an error) +func (h *SCryptHasher) HashPassword(password, salt, config string) (string, string, error) { + var tempPasswd []byte + if config == "fallback" { + // Fixed default config to match with original configuration + config = h.fallback + } + + split := strings.Split(config, "$") + if len(split) != 4 { + split = strings.Split(h.getConfigFromSetting(), "$") + } + + var n, r, p, keyLength int + var err error + + if n, err = strconv.Atoi(split[0]); err != nil { + return "", "", err + } + if r, err = strconv.Atoi(split[1]); err != nil { + return "", "", err + } + if p, err = strconv.Atoi(split[2]); err != nil { + return "", "", err + } + if keyLength, err = strconv.Atoi(split[3]); err != nil { + return "", "", err + } + + tempPasswd, _ = scrypt.Key([]byte(password), []byte(salt), n, r, p, keyLength) + return fmt.Sprintf("%x", tempPasswd), + fmt.Sprintf("scrypt$%d$%d$%d$%d", n, r, p, keyLength), + nil +} + +// Validate validates a plain-text password +func (h *SCryptHasher) Validate(password, hash, salt, config string) bool { + tempHash, _, _ := h.HashPassword(password, salt, config) + return subtle.ConstantTimeCompare([]byte(hash), []byte(tempHash)) == 1 +} + +func (h *SCryptHasher) getConfigFromAlgo(algo string) string { + split := strings.SplitN(algo, "$", 2) + if len(split) == 1 { + split[1] = "fallback" + } + return split[1] +} + +func (h *SCryptHasher) getConfigFromSetting() string { + if h.N == 0 || h.R == 0 || h.P == 0 || h.KeyLength == 0 { + return h.fallback + } + return fmt.Sprintf("%d$%d$%d$%d", h.N, h.R, h.P, h.KeyLength) +} + +func init() { + DefaultHasher.Hashers["scrypt"] = &SCryptHasher{65536, 16, 2, 50, "65536$16$2$50"} +} diff --git a/modules/setting/password_hash.go b/modules/setting/password_hash.go new file mode 100644 index 0000000000000..f866dff1d7e2b --- /dev/null +++ b/modules/setting/password_hash.go @@ -0,0 +1,25 @@ +// Copyright 2021 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 setting + +import ( + "code.gitea.io/gitea/modules/auth/hash" + "code.gitea.io/gitea/modules/log" +) + +func newPasswordHashService() { + passwordHashAlgo := Cfg.Section("security").Key("PASSWORD_HASH_ALGO").MustString("pbkdf2") + + if _, ok := hash.DefaultHasher.Hashers[passwordHashAlgo]; !ok { + log.Error("Unknown default hashing algorithm: %s. Keeping default: %s", passwordHashAlgo, hash.DefaultHasher.DefaultAlgorithm) + } else { + hash.DefaultHasher.DefaultAlgorithm = passwordHashAlgo + } + + sec := Cfg.Section("security.password_hash") + for _, hasher := range hash.DefaultHasher.Hashers { + _ = sec.MapTo(hasher) + } +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index e3da5796e4268..94dc6e7e328ab 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -187,7 +187,6 @@ var ( DisableWebhooks bool OnlyAllowPushIfGiteaEnvironmentSet bool PasswordComplexity []string - PasswordHashAlgo string PasswordCheckPwn bool // UI settings @@ -837,10 +836,11 @@ func NewContext() { DisableGitHooks = sec.Key("DISABLE_GIT_HOOKS").MustBool(true) DisableWebhooks = sec.Key("DISABLE_WEBHOOKS").MustBool(false) OnlyAllowPushIfGiteaEnvironmentSet = sec.Key("ONLY_ALLOW_PUSH_IF_GITEA_ENVIRONMENT_SET").MustBool(true) - PasswordHashAlgo = sec.Key("PASSWORD_HASH_ALGO").MustString("pbkdf2") CSRFCookieHTTPOnly = sec.Key("CSRF_COOKIE_HTTP_ONLY").MustBool(true) PasswordCheckPwn = sec.Key("PASSWORD_CHECK_PWN").MustBool(false) + newPasswordHashService() + InternalToken = loadInternalToken(sec) cfgdata := sec.Key("PASSWORD_COMPLEXITY").Strings(",") diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 0ead1dfd6d087..6783f6ba635be 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2499,6 +2499,16 @@ config.log_file_root_path = Log Path config.script_type = Script Type config.reverse_auth_user = Reverse Authentication User +config.hasher_selected = Password Hash Algorithm +config.hasher_param_cost = Cost +config.hasher_param_n = CostFactor (N) +config.hasher_param_r = BlockSizeFactor (r) +config.hasher_param_p = ParallelizationFactor (p) +config.hasher_param_keylength = Key Length +config.hasher_param_iterations = Iterations +config.hasher_param_memory = Memory +config.hasher_param_parallelism = Parallelism + config.ssh_config = SSH Configuration config.ssh_enabled = Enabled config.ssh_start_builtin_server = Use Built-In Server diff --git a/routers/install/install.go b/routers/install/install.go index ad985cf184882..8bd4e9ac3a681 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -15,6 +15,7 @@ import ( "time" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth/hash" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/generate" @@ -153,7 +154,7 @@ func Install(ctx *context.Context) { form.DefaultAllowCreateOrganization = setting.Service.DefaultAllowCreateOrganization form.DefaultEnableTimetracking = setting.Service.DefaultEnableTimetracking form.NoReplyAddress = setting.Service.NoReplyAddress - form.PasswordAlgorithm = setting.PasswordHashAlgo + form.PasswordAlgorithm = hash.DefaultHasher.DefaultAlgorithm middleware.AssignForm(form, ctx.Data) ctx.HTML(http.StatusOK, tplInstall) @@ -197,7 +198,7 @@ func SubmitInstall(ctx *context.Context) { setting.Database.Charset = form.Charset setting.Database.Path = form.DbPath - setting.PasswordHashAlgo = form.PasswordAlgorithm + hash.DefaultHasher.DefaultAlgorithm = form.PasswordAlgorithm if (setting.Database.Type == "sqlite3") && len(setting.Database.Path) == 0 { diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go index c2d94ab9c9710..92f658432246c 100644 --- a/routers/web/admin/admin.go +++ b/routers/web/admin/admin.go @@ -16,6 +16,7 @@ import ( "time" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth/hash" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/cron" @@ -315,6 +316,8 @@ func Config(ctx *context.Context) { ctx.Data["DisableRouterLog"] = setting.DisableRouterLog ctx.Data["EnableXORMLog"] = setting.EnableXORMLog ctx.Data["LogSQL"] = setting.Database.LogSQL + ctx.Data["SelectedHasherParams"] = hash.DefaultHasher.Hashers[hash.DefaultHasher.DefaultAlgorithm] + ctx.Data["SelectedHasher"] = hash.DefaultHasher.DefaultAlgorithm ctx.HTML(http.StatusOK, tplConfig) } diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go index b805db620083b..74bcf5db96a52 100644 --- a/routers/web/user/setting/account.go +++ b/routers/web/user/setting/account.go @@ -71,10 +71,11 @@ func AccountPost(ctx *context.Context) { ctx.ServerError("UpdateUser", err) return } - if err := models.UpdateUserCols(ctx.User, "salt", "passwd_hash_algo", "passwd"); err != nil { + if err := models.UpdateUserCols(ctx.User, "salt", "passwd", "passwd_hash_algo"); err != nil { ctx.ServerError("UpdateUser", err) return } + log.Trace("User password updated: %s", ctx.User.Name) ctx.Flash.Success(ctx.Tr("settings.change_password_success")) } diff --git a/templates/admin/config.tmpl b/templates/admin/config.tmpl index b419d04a1b240..fef2227dc03b1 100644 --- a/templates/admin/config.tmpl +++ b/templates/admin/config.tmpl @@ -50,6 +50,38 @@
{{.i18n.Tr "admin.config.reverse_auth_user"}}
{{.ReverseProxyAuthUser}}
+
+ +
{{.i18n.Tr "admin.config.hasher_selected"}}
+
{{.SelectedHasher}}
+ {{ if eq .SelectedHasher "bcrypt" }} +
{{.i18n.Tr "admin.config.hasher_param_cost"}}
+
{{.SelectedHasherParams.Cost}}
+ {{else if eq .SelectedHasher "scrypt"}} +
{{.i18n.Tr "admin.config.hasher_param_n"}}
+
{{.SelectedHasherParams.N}}
+
{{.i18n.Tr "admin.config.hasher_param_r"}}
+
{{.SelectedHasherParams.R}}
+
{{.i18n.Tr "admin.config.hasher_param_p"}}
+
{{.SelectedHasherParams.P}}
+
{{.i18n.Tr "admin.config.hasher_param_keylength"}}
+
{{.SelectedHasherParams.KeyKength}}
+ {{else if eq .SelectedHasher "argon2"}} +
{{.i18n.Tr "admin.config.hasher_param_iterations"}}
+
{{.SelectedHasherParams.Iterations}}
+
{{.i18n.Tr "admin.config.hasher_param_memory"}}
+
{{.SelectedHasherParams.Memory}}
+
{{.i18n.Tr "admin.config.hasher_param_parallelism"}}
+
{{.SelectedHasherParams.Parallelism}}
+
{{.i18n.Tr "admin.config.hasher_param_keylength"}}
+
{{.SelectedHasherParams.KeyLength}}
+ {{else if eq .SelectedHasher "pbkdf2"}} +
{{.i18n.Tr "admin.config.hasher_param_iterations"}}
+
{{.SelectedHasherParams.Iterations}}
+
{{.i18n.Tr "admin.config.hasher_param_keylength"}}
+
{{.SelectedHasherParams.KeyLength}}
+ {{end}} + {{if .EnvVars }}
{{range .EnvVars}}