Skip to content

Commit 96e5698

Browse files
committed
Provide the ability to set password hash algorithm parameters (go-gitea#22942)
Backport go-gitea#22942 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 1d191f9 commit 96e5698

File tree

23 files changed

+732
-85
lines changed

23 files changed

+732
-85
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"

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,21 @@ Certain queues have defaults that override the defaults set in `[queue]` (this o
523523
- `IMPORT_LOCAL_PATHS`: **false**: Set to `false` to prevent all users (including admin) from importing local path on server.
524524
- `INTERNAL_TOKEN`: **\<random at every install if no uri set\>**: Secret used to validate communication within Gitea binary.
525525
- `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`)
526-
- `PASSWORD_HASH_ALGO`: **pbkdf2**: The hash algorithm to use \[argon2, pbkdf2, scrypt, bcrypt\], argon2 will spend more memory than others.
526+
- `PASSWORD_HASH_ALGO`: **pbkdf2**: The hash algorithm to use \[argon2, pbkdf2, pbkdf2_v1, scrypt, bcrypt\], argon2 and scrypt will spend significant amounts of memory.
527+
- Note: The default parameters for `pbkdf2` hashing have changed - the previous settings are available as `pbkdf2_v1` but are not recommended.
528+
- The hash functions may be tuned by using `$` after the algorithm:
529+
- `argon2$<time>$<memory>$<threads>$<key-length>`
530+
- `pbkdf2$<iterations>$<key-length>`
531+
- `scrypt$<n>$<r>$<p>$<key-length>`
532+
- `bcrypt$<cost>`
533+
- The defaults are:
534+
- `argon2`: `argon2$2$65536$8$50`
535+
- `bcrypt`: `bcrypt$10`
536+
- `scrypt`: `scrypt$65536$16$2$50`
537+
- `pbkdf2`: `pbkdf2$320000$50`
538+
- `pbkdf2_v1`: `pbkdf2$10000$50`
539+
- `pbkdf2_v2`: `pbkdf2$320000$50`
540+
- Adjusting the algorithm parameters using this functionality is done at your own risk.
527541
- `CSRF_COOKIE_HTTP_ONLY`: **true**: Set false to allow JavaScript to read CSRF cookie.
528542
- `MIN_PASSWORD_LENGTH`: **6**: Minimum password length for new users.
529543
- `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
@@ -7,8 +7,6 @@ package user
77

88
import (
99
"context"
10-
"crypto/sha256"
11-
"crypto/subtle"
1210
"encoding/hex"
1311
"fmt"
1412
"net/url"
@@ -22,6 +20,7 @@ import (
2220
"code.gitea.io/gitea/models/auth"
2321
"code.gitea.io/gitea/models/db"
2422
"code.gitea.io/gitea/modules/auth/openid"
23+
"code.gitea.io/gitea/modules/auth/password/hash"
2524
"code.gitea.io/gitea/modules/base"
2625
"code.gitea.io/gitea/modules/git"
2726
"code.gitea.io/gitea/modules/log"
@@ -30,10 +29,6 @@ import (
3029
"code.gitea.io/gitea/modules/timeutil"
3130
"code.gitea.io/gitea/modules/util"
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"
@@ -368,42 +348,6 @@ func (u *User) NewGitSig() *git.Signature {
368348
}
369349
}
370350

371-
func hashPassword(passwd, salt, algo string) (string, error) {
372-
var tempPasswd []byte
373-
var saltBytes []byte
374-
375-
// There are two formats for the Salt value:
376-
// * The new format is a (32+)-byte hex-encoded string
377-
// * The old format was a 10-byte binary format
378-
// We have to tolerate both here but Authenticate should
379-
// regenerate the Salt following a successful validation.
380-
if len(salt) == 10 {
381-
saltBytes = []byte(salt)
382-
} else {
383-
var err error
384-
saltBytes, err = hex.DecodeString(salt)
385-
if err != nil {
386-
return "", err
387-
}
388-
}
389-
390-
switch algo {
391-
case algoBcrypt:
392-
tempPasswd, _ = bcrypt.GenerateFromPassword([]byte(passwd), bcrypt.DefaultCost)
393-
return string(tempPasswd), nil
394-
case algoScrypt:
395-
tempPasswd, _ = scrypt.Key([]byte(passwd), saltBytes, 65536, 16, 2, 50)
396-
case algoArgon2:
397-
tempPasswd = argon2.IDKey([]byte(passwd), saltBytes, 2, 65536, 8, 50)
398-
case algoPbkdf2:
399-
fallthrough
400-
default:
401-
tempPasswd = pbkdf2.Key([]byte(passwd), saltBytes, 10000, 50, sha256.New)
402-
}
403-
404-
return fmt.Sprintf("%x", tempPasswd), nil
405-
}
406-
407351
// SetPassword hashes a password using the algorithm defined in the config value of PASSWORD_HASH_ALGO
408352
// change passwd, salt and passwd_hash_algo fields
409353
func (u *User) SetPassword(passwd string) (err error) {
@@ -417,28 +361,17 @@ func (u *User) SetPassword(passwd string) (err error) {
417361
if u.Salt, err = GetUserSalt(); err != nil {
418362
return err
419363
}
420-
if u.Passwd, err = hashPassword(passwd, u.Salt, setting.PasswordHashAlgo); err != nil {
364+
if u.Passwd, err = hash.Parse(setting.PasswordHashAlgo).Hash(passwd, u.Salt); err != nil {
421365
return err
422366
}
423367
u.PasswdHashAlgo = setting.PasswordHashAlgo
424368

425369
return nil
426370
}
427371

428-
// ValidatePassword checks if given password matches the one belongs to the user.
372+
// ValidatePassword checks if the given password matches the one belonging to the user.
429373
func (u *User) ValidatePassword(passwd string) bool {
430-
tempHash, err := hashPassword(passwd, u.Salt, u.PasswdHashAlgo)
431-
if err != nil {
432-
return false
433-
}
434-
435-
if u.PasswdHashAlgo != algoBcrypt && subtle.ConstantTimeCompare([]byte(u.Passwd), []byte(tempHash)) == 1 {
436-
return true
437-
}
438-
if u.PasswdHashAlgo == algoBcrypt && bcrypt.CompareHashAndPassword([]byte(u.Passwd), []byte(passwd)) == nil {
439-
return true
440-
}
441-
return false
374+
return hash.Parse(u.PasswdHashAlgo).VerifyPassword(passwd, u.Passwd, u.Salt)
442375
}
443376

444377
// 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
@@ -13,6 +13,7 @@ import (
1313
"code.gitea.io/gitea/models/db"
1414
"code.gitea.io/gitea/models/unittest"
1515
user_model "code.gitea.io/gitea/models/user"
16+
"code.gitea.io/gitea/modules/auth/password/hash"
1617
"code.gitea.io/gitea/modules/setting"
1718
"code.gitea.io/gitea/modules/structs"
1819
"code.gitea.io/gitea/modules/util"
@@ -162,7 +163,7 @@ func TestEmailNotificationPreferences(t *testing.T) {
162163
func TestHashPasswordDeterministic(t *testing.T) {
163164
b := make([]byte, 16)
164165
u := &user_model.User{}
165-
algos := []string{"argon2", "pbkdf2", "scrypt", "bcrypt"}
166+
algos := hash.RecommendedHashAlgorithms
166167
for j := 0; j < len(algos); j++ {
167168
u.PasswdHashAlgo = algos[j]
168169
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)