Skip to content

Commit 7cad01f

Browse files
committed
Provide the ability to set password hash algorithm parameters
This PR refactors and improves the password hashing code within gitea and makes it possible for server administrators to set the password hashing parameters In addition it takes the opportunity to adjust the settings for `pbkdf2` in order to make the hashing a little stronger. The majority of this work was inspired by PR go-gitea#14751 and I would like to thank @boppy for their work on this. Thanks to @Gusted for the suggestion to adjust the `pbkdf2` hashing parameters. Close go-gitea#14751 Signed-off-by: Andrew Thornton <[email protected]>
1 parent 52dd383 commit 7cad01f

26 files changed

+734
-87
lines changed

cmd/admin_user_change_password.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
"fmt"
1010

1111
user_model "code.gitea.io/gitea/models/user"
12-
pwd "code.gitea.io/gitea/modules/password"
12+
pwd "code.gitea.io/gitea/modules/auth/password"
1313
"code.gitea.io/gitea/modules/setting"
1414

1515
"github.com/urfave/cli"

cmd/admin_user_create.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010

1111
auth_model "code.gitea.io/gitea/models/auth"
1212
user_model "code.gitea.io/gitea/models/user"
13-
pwd "code.gitea.io/gitea/modules/password"
13+
pwd "code.gitea.io/gitea/modules/auth/password"
1414
"code.gitea.io/gitea/modules/setting"
1515
"code.gitea.io/gitea/modules/util"
1616

docs/content/doc/advanced/config-cheat-sheet.en-us.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,21 @@ Certain queues have defaults that override the defaults set in `[queue]` (this o
568568
- `IMPORT_LOCAL_PATHS`: **false**: Set to `false` to prevent all users (including admin) from importing local path on server.
569569
- `INTERNAL_TOKEN`: **\<random at every install if no uri set\>**: Secret used to validate communication within Gitea binary.
570570
- `INTERNAL_TOKEN_URI`: **<empty>**: Instead of defining INTERNAL_TOKEN in the configuration, this configuration option can be used to give Gitea a path to a file that contains the internal token (example value: `file:/etc/gitea/internal_token`)
571-
- `PASSWORD_HASH_ALGO`: **pbkdf2**: The hash algorithm to use \[argon2, pbkdf2, scrypt, bcrypt\], argon2 will spend more memory than others.
571+
- `PASSWORD_HASH_ALGO`: **pbkdf2**: The hash algorithm to use \[argon2, pbkdf2, pbkdf2_v1, scrypt, bcrypt\], argon2 and scrypt will spend significant amounts of memory.
572+
- Note: The default parameters for `pbkdf2` hashing have changed - the previous settings are available as `pbkdf2_v1` but are not recommended.
573+
- The hash functions may be tuned by using `$` after the algorithm:
574+
- `argon2$<time>$<memory>$<threads>$<key-length>`
575+
- `pbkdf2$<iterations>$<key-length>`
576+
- `scrypt$<n>$<r>$<p>$<key-length>`
577+
- `bcrypt$<cost>`
578+
- The defaults are:
579+
- `argon2`: `argon2$2$65536$8$50`
580+
- `bcrypt`: `bcrypt$10`
581+
- `scrypt`: `scrypt$65536$16$2$50`
582+
- `pbkdf2`: `pbkdf2$320000$50`
583+
- `pbkdf2_v1`: `pbkdf2$10000$50`
584+
- `pbkdf2_v2`: `pbkdf2$320000$50`
585+
- Adjusting the algorithm parameters using this functionality is done at your own risk.
572586
- `CSRF_COOKIE_HTTP_ONLY`: **true**: Set false to allow JavaScript to read CSRF cookie.
573587
- `MIN_PASSWORD_LENGTH`: **6**: Minimum password length for new users.
574588
- `PASSWORD_COMPLEXITY`: **off**: Comma separated list of character classes required to pass minimum complexity. If left empty or no valid values are specified, checking is disabled (off):

models/user/user.go

Lines changed: 4 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ package user
66

77
import (
88
"context"
9-
"crypto/sha256"
10-
"crypto/subtle"
119
"encoding/hex"
1210
"fmt"
1311
"net/url"
@@ -21,6 +19,7 @@ import (
2119
"code.gitea.io/gitea/models/auth"
2220
"code.gitea.io/gitea/models/db"
2321
"code.gitea.io/gitea/modules/auth/openid"
22+
"code.gitea.io/gitea/modules/auth/password/hash"
2423
"code.gitea.io/gitea/modules/base"
2524
"code.gitea.io/gitea/modules/git"
2625
"code.gitea.io/gitea/modules/log"
@@ -30,10 +29,6 @@ import (
3029
"code.gitea.io/gitea/modules/util"
3130
"code.gitea.io/gitea/modules/validation"
3231

33-
"golang.org/x/crypto/argon2"
34-
"golang.org/x/crypto/bcrypt"
35-
"golang.org/x/crypto/pbkdf2"
36-
"golang.org/x/crypto/scrypt"
3732
"xorm.io/builder"
3833
)
3934

@@ -48,21 +43,6 @@ const (
4843
UserTypeOrganization
4944
)
5045

51-
const (
52-
algoBcrypt = "bcrypt"
53-
algoScrypt = "scrypt"
54-
algoArgon2 = "argon2"
55-
algoPbkdf2 = "pbkdf2"
56-
)
57-
58-
// AvailableHashAlgorithms represents the available password hashing algorithms
59-
var AvailableHashAlgorithms = []string{
60-
algoPbkdf2,
61-
algoArgon2,
62-
algoScrypt,
63-
algoBcrypt,
64-
}
65-
6646
const (
6747
// EmailNotificationsEnabled indicates that the user would like to receive all email notifications except your own
6848
EmailNotificationsEnabled = "enabled"
@@ -377,42 +357,6 @@ func (u *User) NewGitSig() *git.Signature {
377357
}
378358
}
379359

380-
func hashPassword(passwd, salt, algo string) (string, error) {
381-
var tempPasswd []byte
382-
var saltBytes []byte
383-
384-
// There are two formats for the Salt value:
385-
// * The new format is a (32+)-byte hex-encoded string
386-
// * The old format was a 10-byte binary format
387-
// We have to tolerate both here but Authenticate should
388-
// regenerate the Salt following a successful validation.
389-
if len(salt) == 10 {
390-
saltBytes = []byte(salt)
391-
} else {
392-
var err error
393-
saltBytes, err = hex.DecodeString(salt)
394-
if err != nil {
395-
return "", err
396-
}
397-
}
398-
399-
switch algo {
400-
case algoBcrypt:
401-
tempPasswd, _ = bcrypt.GenerateFromPassword([]byte(passwd), bcrypt.DefaultCost)
402-
return string(tempPasswd), nil
403-
case algoScrypt:
404-
tempPasswd, _ = scrypt.Key([]byte(passwd), saltBytes, 65536, 16, 2, 50)
405-
case algoArgon2:
406-
tempPasswd = argon2.IDKey([]byte(passwd), saltBytes, 2, 65536, 8, 50)
407-
case algoPbkdf2:
408-
fallthrough
409-
default:
410-
tempPasswd = pbkdf2.Key([]byte(passwd), saltBytes, 10000, 50, sha256.New)
411-
}
412-
413-
return hex.EncodeToString(tempPasswd), nil
414-
}
415-
416360
// SetPassword hashes a password using the algorithm defined in the config value of PASSWORD_HASH_ALGO
417361
// change passwd, salt and passwd_hash_algo fields
418362
func (u *User) SetPassword(passwd string) (err error) {
@@ -426,28 +370,17 @@ func (u *User) SetPassword(passwd string) (err error) {
426370
if u.Salt, err = GetUserSalt(); err != nil {
427371
return err
428372
}
429-
if u.Passwd, err = hashPassword(passwd, u.Salt, setting.PasswordHashAlgo); err != nil {
373+
if u.Passwd, err = hash.Parse(setting.PasswordHashAlgo).Hash(passwd, u.Salt); err != nil {
430374
return err
431375
}
432376
u.PasswdHashAlgo = setting.PasswordHashAlgo
433377

434378
return nil
435379
}
436380

437-
// ValidatePassword checks if given password matches the one belongs to the user.
381+
// ValidatePassword checks if the given password matches the one belonging to the user.
438382
func (u *User) ValidatePassword(passwd string) bool {
439-
tempHash, err := hashPassword(passwd, u.Salt, u.PasswdHashAlgo)
440-
if err != nil {
441-
return false
442-
}
443-
444-
if u.PasswdHashAlgo != algoBcrypt && subtle.ConstantTimeCompare([]byte(u.Passwd), []byte(tempHash)) == 1 {
445-
return true
446-
}
447-
if u.PasswdHashAlgo == algoBcrypt && bcrypt.CompareHashAndPassword([]byte(u.Passwd), []byte(passwd)) == nil {
448-
return true
449-
}
450-
return false
383+
return hash.Parse(u.PasswdHashAlgo).VerifyPassword(passwd, u.Passwd, u.Salt)
451384
}
452385

453386
// IsPasswordSet checks if the password is set or left empty

models/user/user_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"code.gitea.io/gitea/models/db"
1515
"code.gitea.io/gitea/models/unittest"
1616
user_model "code.gitea.io/gitea/models/user"
17+
"code.gitea.io/gitea/modules/auth/password/hash"
1718
"code.gitea.io/gitea/modules/setting"
1819
"code.gitea.io/gitea/modules/structs"
1920
"code.gitea.io/gitea/modules/timeutil"
@@ -164,7 +165,7 @@ func TestEmailNotificationPreferences(t *testing.T) {
164165
func TestHashPasswordDeterministic(t *testing.T) {
165166
b := make([]byte, 16)
166167
u := &user_model.User{}
167-
algos := []string{"argon2", "pbkdf2", "scrypt", "bcrypt"}
168+
algos := hash.RecommendedHashAlgorithms
168169
for j := 0; j < len(algos); j++ {
169170
u.PasswdHashAlgo = algos[j]
170171
for i := 0; i < 50; i++ {

modules/auth/password/hash/argon2.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package hash
5+
6+
import (
7+
"encoding/hex"
8+
"strings"
9+
10+
"code.gitea.io/gitea/modules/log"
11+
"golang.org/x/crypto/argon2"
12+
)
13+
14+
func init() {
15+
registerHasher("argon2", NewArgon2Hasher)
16+
}
17+
18+
// Argon2Hasher implements PasswordHasher
19+
// and uses the Argon2 key derivation function, hybrant variant
20+
type Argon2Hasher struct {
21+
time uint32
22+
memory uint32
23+
threads uint8
24+
keyLen uint32
25+
}
26+
27+
// HashWithSaltBytes a provided password and salt
28+
func (hasher *Argon2Hasher) HashWithSaltBytes(password string, salt []byte) string {
29+
if hasher == nil {
30+
return ""
31+
}
32+
return hex.EncodeToString(argon2.IDKey([]byte(password), salt, hasher.time, hasher.memory, hasher.threads, hasher.keyLen))
33+
}
34+
35+
// NewArgon2Hasher is a factory method to create an Argon2Hasher
36+
// The provided config should be either empty or of the form:
37+
// "<time>$<memory>$<threads>$<keyLen>", where <x> is the string representation
38+
// of an integer
39+
func NewArgon2Hasher(config string) *Argon2Hasher {
40+
// This default configuration uses the following parameters:
41+
// time=2, memory=64*1024, threads=8, keyLen=50.
42+
// It will make two passes through the memory, using 64MiB in total.
43+
hasher := &Argon2Hasher{
44+
time: 2,
45+
memory: 1 << 16,
46+
threads: 8,
47+
keyLen: 50,
48+
}
49+
50+
if config == "" {
51+
return hasher
52+
}
53+
54+
vals := strings.SplitN(config, "$", 4)
55+
if len(vals) != 4 {
56+
log.Error("invalid argon2 hash spec %s", config)
57+
return nil
58+
}
59+
60+
parsed, err := parseUIntParam(vals[0], "time", "argon2", config, nil)
61+
hasher.time = uint32(parsed)
62+
63+
parsed, err = parseUIntParam(vals[1], "memory", "argon2", config, err)
64+
hasher.memory = uint32(parsed)
65+
66+
parsed, err = parseUIntParam(vals[2], "threads", "argon2", config, err)
67+
hasher.threads = uint8(parsed)
68+
69+
parsed, err = parseUIntParam(vals[3], "keyLen", "argon2", config, err)
70+
hasher.keyLen = uint32(parsed)
71+
if err != nil {
72+
return nil
73+
}
74+
75+
return hasher
76+
}

modules/auth/password/hash/bcrypt.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package hash
5+
6+
import (
7+
"golang.org/x/crypto/bcrypt"
8+
)
9+
10+
func init() {
11+
registerHasher("bcrypt", NewBcryptHasher)
12+
}
13+
14+
// BcryptHasher implements PasswordHasher
15+
// and uses the bcrypt password hash function.
16+
type BcryptHasher struct {
17+
cost int
18+
}
19+
20+
// HashWithSaltBytes a provided password and salt
21+
func (hasher *BcryptHasher) HashWithSaltBytes(password string, salt []byte) string {
22+
if hasher == nil {
23+
return ""
24+
}
25+
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), hasher.cost)
26+
return string(hashedPassword)
27+
}
28+
29+
func (hasher *BcryptHasher) VerifyPassword(password, hashedPassword, salt string) bool {
30+
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) == nil
31+
}
32+
33+
// NewBcryptHasher is a factory method to create an BcryptHasher
34+
// The provided config should be either empty or the string representation of the "<cost>"
35+
// as an integer
36+
func NewBcryptHasher(config string) *BcryptHasher {
37+
hasher := &BcryptHasher{
38+
cost: 10, // cost=10. i.e. 2^10 rounds of key expansion.
39+
}
40+
41+
if config == "" {
42+
return hasher
43+
}
44+
var err error
45+
hasher.cost, err = parseIntParam(config, "cost", "bcrypt", config, nil)
46+
if err != nil {
47+
return nil
48+
}
49+
50+
return hasher
51+
}

modules/auth/password/hash/common.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package hash
5+
6+
import (
7+
"strconv"
8+
9+
"code.gitea.io/gitea/modules/log"
10+
)
11+
12+
func parseIntParam(value, param, algorithmName, config string, previousErr error) (int, error) {
13+
parsed, err := strconv.Atoi(value)
14+
if err != nil {
15+
log.Error("invalid integer for %s representation in %s hash spec %s", param, algorithmName, config)
16+
return 0, err
17+
}
18+
return parsed, previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed
19+
}
20+
21+
func parseUIntParam(value, param, algorithmName, config string, previousErr error) (uint64, error) {
22+
parsed, err := strconv.ParseUint(value, 10, 64)
23+
if err != nil {
24+
log.Error("invalid integer for %s representation in %s hash spec %s", param, algorithmName, config)
25+
return 0, err
26+
}
27+
return parsed, previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed
28+
}

0 commit comments

Comments
 (0)