Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce ability to update HashAlgo #14751

Closed
wants to merge 15 commits into from
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,25 @@ CSRF_COOKIE_HTTP_ONLY = true
; Validate against https://haveibeenpwned.com/Passwords to see if a password has been exposed
PASSWORD_CHECK_PWN = false

[security.hash]
BCRYPT_COST = 10

; Parameters for scrypt
SCRYPT_N = 65536
SCRYPT_R = 16
SCRYPT_P = 2
SCRYPT_KEY_LENGTH = 50

; Parameters for Argon2id
ARGON2_ITERATIONS = 2
ARGON2_MEMORY = 65536
ARGON2_PARALLELISM = 8
ARGON2_KEY_LENGTH = 50

; Parameters for Pbkdf2
PBKDF2_ITERATIONS = 10000
PBKDF2_KEY_LENGTH = 50

[openid]
;
; OpenID is an open, standard and decentralized authentication protocol.
Expand Down
19 changes: 18 additions & 1 deletion models/fixtures/user.yml
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,6 @@
num_repos: 0
is_active: true


-
id: 30
lower_name: user30
Expand All @@ -525,3 +524,21 @@
avatar_email: user30@example.com
num_repos: 2
is_active: true

-
id: 31
lower_name: user31
name: user31
full_name: User ThirtyOne
email: user31@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: user31@example.com
num_repos: 0
is_active: true
4 changes: 2 additions & 2 deletions models/login_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ import (
"strconv"
"strings"

"code.gitea.io/gitea/modules/auth/db"
"code.gitea.io/gitea/modules/auth/ldap"
"code.gitea.io/gitea/modules/auth/oauth2"
"code.gitea.io/gitea/modules/auth/pam"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"

Expand Down Expand Up @@ -770,7 +770,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 db.DefaultHasher.PasswordNeedUpdate(user.PasswdHashAlgo) {
if err = user.SetPassword(password); err != nil {
return nil, err
}
Expand Down
45 changes: 6 additions & 39 deletions models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ package models
import (
"container/list"
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
Expand All @@ -21,6 +19,7 @@ import (
"time"
"unicode/utf8"

"code.gitea.io/gitea/modules/auth/db"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/generate"
"code.gitea.io/gitea/modules/git"
Expand All @@ -32,10 +31,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"
)
Expand Down Expand Up @@ -384,56 +379,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)

u.Passwd, u.PasswdHashAlgo, _ = db.DefaultHasher.HashPassword(passwd, u.Salt, "")
zeripath marked this conversation as resolved.
Show resolved Hide resolved

return nil
}

// 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 db.DefaultHasher.Validate(passwd, u.Passwd, u.Salt, u.PasswdHashAlgo)
}

// IsPasswordSet checks if the password is set or left empty
Expand Down
47 changes: 44 additions & 3 deletions models/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,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, 31})

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, 31})

testUserSuccess(&SearchUserOptions{Keyword: "user1", OrderBy: "id ASC", ListOptions: ListOptions{Page: 1}, IsActive: util.OptionalBoolTrue},
[]int64{1, 10, 11, 12, 13, 14, 15, 16, 18})
Expand Down Expand Up @@ -223,7 +223,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]
setting.PasswordHashAlgo = algos[j]
for i := 0; i < 50; i++ {
// generate a random password
rand.Read(b)
Expand All @@ -232,17 +232,58 @@ 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: 31}).(*User)

setting.PasswordHashAlgo = "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
setting.Argon2Iterations = 2
setting.Argon2Memory = 65536
setting.Argon2Parallelism = 8
setting.Argon2KeyLength = 50

user, _ := UserSignIn("user31", 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.
Expand Down
84 changes: 84 additions & 0 deletions modules/auth/db/hash.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// 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 db

import (
"strings"

"code.gitea.io/gitea/modules/setting"
)

// DefaultHasherStruct stores the available hashing instances
type DefaultHasherStruct struct {
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) {
if setting.PasswordHashAlgo == "" {
setting.PasswordHashAlgo = "pbkdf2"
}
zeripath marked this conversation as resolved.
Show resolved Hide resolved
return d.Hashers[setting.PasswordHashAlgo].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 != setting.PasswordHashAlgo {
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

func init() {
DefaultHasher = DefaultHasherStruct{}
DefaultHasher.Hashers = make(map[string]Hasher)
}
Loading