初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MustRegister("argon2", NewArgon2Hasher)
|
||||
}
|
||||
|
||||
// Argon2Hasher implements PasswordHasher
|
||||
// and uses the Argon2 key derivation function, hybrant variant
|
||||
type Argon2Hasher struct {
|
||||
time uint32
|
||||
memory uint32
|
||||
threads uint8
|
||||
keyLen uint32
|
||||
}
|
||||
|
||||
// HashWithSaltBytes a provided password and salt
|
||||
func (hasher *Argon2Hasher) HashWithSaltBytes(password string, salt []byte) string {
|
||||
if hasher == nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(argon2.IDKey([]byte(password), salt, hasher.time, hasher.memory, hasher.threads, hasher.keyLen))
|
||||
}
|
||||
|
||||
// NewArgon2Hasher is a factory method to create an Argon2Hasher
|
||||
// The provided config should be either empty or of the form:
|
||||
// "<time>$<memory>$<threads>$<keyLen>", where <x> is the string representation
|
||||
// of an integer
|
||||
func NewArgon2Hasher(config string) *Argon2Hasher {
|
||||
// This default configuration uses the following parameters:
|
||||
// time=2, memory=64*1024, threads=8, keyLen=50.
|
||||
// It will make two passes through the memory, using 64MiB in total.
|
||||
// This matches the original configuration for `argon2` prior to storing hash parameters
|
||||
// in the database.
|
||||
// THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK
|
||||
hasher := &Argon2Hasher{
|
||||
time: 2,
|
||||
memory: 1 << 16,
|
||||
threads: 8,
|
||||
keyLen: 50,
|
||||
}
|
||||
|
||||
if config == "" {
|
||||
return hasher
|
||||
}
|
||||
|
||||
vals := strings.SplitN(config, "$", 4)
|
||||
if len(vals) != 4 {
|
||||
log.Error("invalid argon2 hash spec %s", config)
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
hasher.time, err = parseUintParam[uint32](vals[0], "time", "argon2", config, nil)
|
||||
hasher.memory, err = parseUintParam[uint32](vals[1], "memory", "argon2", config, err)
|
||||
hasher.threads, err = parseUintParam[uint8](vals[2], "threads", "argon2", config, err)
|
||||
hasher.keyLen, err = parseUintParam[uint32](vals[3], "keyLen", "argon2", config, err)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return hasher
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MustRegister("bcrypt", NewBcryptHasher)
|
||||
}
|
||||
|
||||
// BcryptHasher implements PasswordHasher
|
||||
// and uses the bcrypt password hash function.
|
||||
type BcryptHasher struct {
|
||||
cost int
|
||||
}
|
||||
|
||||
// HashWithSaltBytes a provided password and salt
|
||||
func (hasher *BcryptHasher) HashWithSaltBytes(password string, salt []byte) string {
|
||||
if hasher == nil {
|
||||
return ""
|
||||
}
|
||||
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), hasher.cost)
|
||||
return string(hashedPassword)
|
||||
}
|
||||
|
||||
func (hasher *BcryptHasher) VerifyPassword(password, hashedPassword, salt string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// NewBcryptHasher is a factory method to create an BcryptHasher
|
||||
// The provided config should be either empty or the string representation of the "<cost>"
|
||||
// as an integer
|
||||
func NewBcryptHasher(config string) *BcryptHasher {
|
||||
// This matches the original configuration for `bcrypt` prior to storing hash parameters
|
||||
// in the database.
|
||||
// THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK
|
||||
hasher := &BcryptHasher{
|
||||
cost: 10, // cost=10. i.e. 2^10 rounds of key expansion.
|
||||
}
|
||||
|
||||
if config == "" {
|
||||
return hasher
|
||||
}
|
||||
var err error
|
||||
hasher.cost, err = parseIntParam(config, "cost", "bcrypt", config, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return hasher
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
func parseIntParam(value, param, algorithmName, config string, previousErr error) (int, error) {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
log.Error("invalid integer for %s representation in %s hash spec %s", param, algorithmName, config)
|
||||
return 0, err
|
||||
}
|
||||
return parsed, previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed
|
||||
}
|
||||
|
||||
func parseUintParam[T uint32 | uint8](value, param, algorithmName, config string, previousErr error) (ret T, _ error) {
|
||||
_, isUint32 := any(ret).(uint32)
|
||||
parsed, err := strconv.ParseUint(value, 10, util.Iif(isUint32, 32, 8))
|
||||
if err != nil {
|
||||
log.Error("invalid integer for %s representation in %s hash spec %s", param, algorithmName, config)
|
||||
return 0, err
|
||||
}
|
||||
return T(parsed), previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// DummyHasher implements PasswordHasher and is a dummy hasher that simply
|
||||
// puts the password in place with its salt
|
||||
// This SHOULD NOT be used in production and is provided to make the integration
|
||||
// tests faster only
|
||||
type DummyHasher struct{}
|
||||
|
||||
// HashWithSaltBytes a provided password and salt
|
||||
func (hasher *DummyHasher) HashWithSaltBytes(password string, salt []byte) string {
|
||||
if hasher == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(salt) == 10 {
|
||||
return string(salt) + ":" + password
|
||||
}
|
||||
|
||||
return hex.EncodeToString(salt) + ":" + password
|
||||
}
|
||||
|
||||
// NewDummyHasher is a factory method to create a DummyHasher
|
||||
// Any provided configuration is ignored
|
||||
func NewDummyHasher(_ string) *DummyHasher {
|
||||
return &DummyHasher{}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDummyHasher(t *testing.T) {
|
||||
dummy := &PasswordHashAlgorithm{
|
||||
PasswordSaltHasher: NewDummyHasher(""),
|
||||
Specification: "dummy",
|
||||
}
|
||||
|
||||
password, salt := "password", "ZogKvWdyEx"
|
||||
|
||||
hash, err := dummy.Hash(password, salt)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, hash, salt+":"+password)
|
||||
|
||||
assert.True(t, dummy.VerifyPassword(password, hash, salt))
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// This package takes care of hashing passwords, verifying passwords, defining
|
||||
// available password algorithms, defining recommended password algorithms and
|
||||
// choosing the default password algorithm.
|
||||
|
||||
// PasswordSaltHasher will hash a provided password with the provided saltBytes
|
||||
type PasswordSaltHasher interface {
|
||||
HashWithSaltBytes(password string, saltBytes []byte) string
|
||||
}
|
||||
|
||||
// PasswordHasher will hash a provided password with the salt
|
||||
type PasswordHasher interface {
|
||||
Hash(password, salt string) (string, error)
|
||||
}
|
||||
|
||||
// PasswordVerifier will ensure that a providedPassword matches the hashPassword when hashed with the salt
|
||||
type PasswordVerifier interface {
|
||||
VerifyPassword(providedPassword, hashedPassword, salt string) bool
|
||||
}
|
||||
|
||||
// PasswordHashAlgorithms are named PasswordSaltHashers with a default verifier and hash function
|
||||
type PasswordHashAlgorithm struct {
|
||||
PasswordSaltHasher
|
||||
Specification string // The specification that is used to create the internal PasswordSaltHasher
|
||||
}
|
||||
|
||||
// Hash the provided password with the salt and return the hash
|
||||
func (algorithm *PasswordHashAlgorithm) Hash(password, salt string) (string, error) {
|
||||
var saltBytes []byte
|
||||
|
||||
// There are two formats for the salt value:
|
||||
// * The new format is a (32+)-byte hex-encoded string
|
||||
// * The old format was a 10-byte binary format
|
||||
// We have to tolerate both here.
|
||||
if len(salt) == 10 {
|
||||
saltBytes = []byte(salt)
|
||||
} else {
|
||||
var err error
|
||||
saltBytes, err = hex.DecodeString(salt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return algorithm.HashWithSaltBytes(password, saltBytes), nil
|
||||
}
|
||||
|
||||
// Verify the provided password matches the hashPassword when hashed with the salt
|
||||
func (algorithm *PasswordHashAlgorithm) VerifyPassword(providedPassword, hashedPassword, salt string) bool {
|
||||
// Some PasswordSaltHashers have their own specialised compare function that takes into
|
||||
// account the stored parameters within the hash. e.g. bcrypt
|
||||
if verifier, ok := algorithm.PasswordSaltHasher.(PasswordVerifier); ok {
|
||||
return verifier.VerifyPassword(providedPassword, hashedPassword, salt)
|
||||
}
|
||||
|
||||
// Compute the hash of the password.
|
||||
providedPasswordHash, err := algorithm.Hash(providedPassword, salt)
|
||||
if err != nil {
|
||||
log.Error("passwordhash: %v.Hash(): %v", algorithm.Specification, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare it against the hashed password in constant-time.
|
||||
return subtle.ConstantTimeCompare([]byte(hashedPassword), []byte(providedPasswordHash)) == 1
|
||||
}
|
||||
|
||||
var (
|
||||
lastNonDefaultAlgorithm atomic.Value
|
||||
availableHasherFactories = map[string]func(string) PasswordSaltHasher{}
|
||||
)
|
||||
|
||||
// MustRegister registers a PasswordSaltHasher with the availableHasherFactories
|
||||
// Caution: This is not thread safe.
|
||||
func MustRegister[T PasswordSaltHasher](name string, newFn func(config string) T) {
|
||||
if err := Register(name, newFn); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Register registers a PasswordSaltHasher with the availableHasherFactories
|
||||
// Caution: This is not thread safe.
|
||||
func Register[T PasswordSaltHasher](name string, newFn func(config string) T) error {
|
||||
if _, has := availableHasherFactories[name]; has {
|
||||
return fmt.Errorf("duplicate registration of password salt hasher: %s", name)
|
||||
}
|
||||
|
||||
availableHasherFactories[name] = func(config string) PasswordSaltHasher {
|
||||
n := newFn(config)
|
||||
return n
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// In early versions of gitea the password hash algorithm field of a user could be
|
||||
// empty. At that point the default was `pbkdf2` without configuration values
|
||||
//
|
||||
// Please note this is not the same as the DefaultAlgorithm which is used
|
||||
// to determine what an empty PASSWORD_HASH_ALGO setting in the app.ini means.
|
||||
// These are not the same even if they have the same apparent value and they mean different things.
|
||||
//
|
||||
// DO NOT COALESCE THESE VALUES
|
||||
const defaultEmptyHashAlgorithmSpecification = "pbkdf2"
|
||||
|
||||
// Parse will convert the provided algorithm specification in to a PasswordHashAlgorithm
|
||||
// If the provided specification matches the DefaultHashAlgorithm Specification it will be
|
||||
// used.
|
||||
// In addition the last non-default hasher will be cached to help reduce the load from
|
||||
// parsing specifications.
|
||||
//
|
||||
// NOTE: No de-aliasing is done in this function, thus any specification which does not
|
||||
// contain a configuration will use the default values for that hasher. These are not
|
||||
// necessarily the same values as those obtained by dealiasing. This allows for
|
||||
// seamless backwards compatibility with the original configuration.
|
||||
//
|
||||
// To further labour this point, running `Parse("pbkdf2")` does not obtain the
|
||||
// same algorithm as setting `PASSWORD_HASH_ALGO=pbkdf2` in app.ini, nor is it intended to.
|
||||
// A user that has `password_hash_algo='pbkdf2'` in the db means get the original, unconfigured algorithm
|
||||
// Users will be migrated automatically as they log-in to have the complete specification stored
|
||||
// in their `password_hash_algo` fields by other code.
|
||||
func Parse(algorithmSpec string) *PasswordHashAlgorithm {
|
||||
if algorithmSpec == "" {
|
||||
algorithmSpec = defaultEmptyHashAlgorithmSpecification
|
||||
}
|
||||
|
||||
if DefaultHashAlgorithm != nil && algorithmSpec == DefaultHashAlgorithm.Specification {
|
||||
return DefaultHashAlgorithm
|
||||
}
|
||||
|
||||
ptr := lastNonDefaultAlgorithm.Load()
|
||||
if ptr != nil {
|
||||
hashAlgorithm, ok := ptr.(*PasswordHashAlgorithm)
|
||||
if ok && hashAlgorithm.Specification == algorithmSpec {
|
||||
return hashAlgorithm
|
||||
}
|
||||
}
|
||||
|
||||
// Now convert the provided specification in to a hasherType +/- some configuration parameters
|
||||
vals := strings.SplitN(algorithmSpec, "$", 2)
|
||||
var hasherType string
|
||||
var config string
|
||||
|
||||
if len(vals) == 0 {
|
||||
// This should not happen as algorithmSpec should not be empty
|
||||
// due to it being assigned to defaultEmptyHashAlgorithmSpecification above
|
||||
// but we should be absolutely cautious here
|
||||
return nil
|
||||
}
|
||||
|
||||
hasherType = vals[0]
|
||||
if len(vals) > 1 {
|
||||
config = vals[1]
|
||||
}
|
||||
|
||||
newFn, has := availableHasherFactories[hasherType]
|
||||
if !has {
|
||||
// unknown hasher type
|
||||
return nil
|
||||
}
|
||||
|
||||
ph := newFn(config)
|
||||
if ph == nil {
|
||||
// The provided configuration is likely invalid - it will have been logged already
|
||||
// but we cannot hash safely
|
||||
return nil
|
||||
}
|
||||
|
||||
hashAlgorithm := &PasswordHashAlgorithm{
|
||||
PasswordSaltHasher: ph,
|
||||
Specification: algorithmSpec,
|
||||
}
|
||||
|
||||
lastNonDefaultAlgorithm.Store(hashAlgorithm)
|
||||
|
||||
return hashAlgorithm
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type testSaltHasher string
|
||||
|
||||
func (t testSaltHasher) HashWithSaltBytes(password string, salt []byte) string {
|
||||
return password + "$" + string(salt) + "$" + string(t)
|
||||
}
|
||||
|
||||
func Test_registerHasher(t *testing.T) {
|
||||
MustRegister("Test_registerHasher", func(config string) testSaltHasher {
|
||||
return testSaltHasher(config)
|
||||
})
|
||||
|
||||
assert.Panics(t, func() {
|
||||
MustRegister("Test_registerHasher", func(config string) testSaltHasher {
|
||||
return testSaltHasher(config)
|
||||
})
|
||||
})
|
||||
|
||||
assert.Error(t, Register("Test_registerHasher", func(config string) testSaltHasher {
|
||||
return testSaltHasher(config)
|
||||
}))
|
||||
|
||||
assert.Equal(t, "password$salt$",
|
||||
Parse("Test_registerHasher").PasswordSaltHasher.HashWithSaltBytes("password", []byte("salt")))
|
||||
|
||||
assert.Equal(t, "password$salt$config",
|
||||
Parse("Test_registerHasher$config").PasswordSaltHasher.HashWithSaltBytes("password", []byte("salt")))
|
||||
|
||||
delete(availableHasherFactories, "Test_registerHasher")
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
hashAlgorithmsToTest := []string{}
|
||||
for plainHashAlgorithmNames := range availableHasherFactories {
|
||||
hashAlgorithmsToTest = append(hashAlgorithmsToTest, plainHashAlgorithmNames)
|
||||
}
|
||||
for _, aliased := range aliasAlgorithmNames {
|
||||
if strings.Contains(aliased, "$") {
|
||||
hashAlgorithmsToTest = append(hashAlgorithmsToTest, aliased)
|
||||
}
|
||||
}
|
||||
for _, algorithmName := range hashAlgorithmsToTest {
|
||||
t.Run(algorithmName, func(t *testing.T) {
|
||||
algo := Parse(algorithmName)
|
||||
assert.NotNil(t, algo, "Algorithm %s resulted in an empty algorithm", algorithmName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashing(t *testing.T) {
|
||||
hashAlgorithmsToTest := []string{}
|
||||
for plainHashAlgorithmNames := range availableHasherFactories {
|
||||
hashAlgorithmsToTest = append(hashAlgorithmsToTest, plainHashAlgorithmNames)
|
||||
}
|
||||
for _, aliased := range aliasAlgorithmNames {
|
||||
if strings.Contains(aliased, "$") {
|
||||
hashAlgorithmsToTest = append(hashAlgorithmsToTest, aliased)
|
||||
}
|
||||
}
|
||||
|
||||
runTests := func(password, salt string, shouldPass bool) {
|
||||
for _, algorithmName := range hashAlgorithmsToTest {
|
||||
t.Run(algorithmName, func(t *testing.T) {
|
||||
output, err := Parse(algorithmName).Hash(password, salt)
|
||||
if shouldPass {
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, output, "output for %s was empty", algorithmName)
|
||||
} else {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, Parse(algorithmName).VerifyPassword(password, output, salt), shouldPass)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test with new salt format.
|
||||
runTests(strings.Repeat("a", 16), hex.EncodeToString([]byte{0x01, 0x02, 0x03}), true)
|
||||
|
||||
// Test with legacy salt format.
|
||||
runTests(strings.Repeat("a", 16), strings.Repeat("b", 10), true)
|
||||
|
||||
// Test with invalid salt.
|
||||
runTests(strings.Repeat("a", 16), "a", false)
|
||||
}
|
||||
|
||||
// vectors were generated using the current codebase.
|
||||
var vectors = []struct {
|
||||
algorithms []string
|
||||
password string
|
||||
salt string
|
||||
output string
|
||||
shouldfail bool
|
||||
}{
|
||||
{
|
||||
algorithms: []string{"bcrypt", "bcrypt$10"},
|
||||
password: "abcdef",
|
||||
salt: strings.Repeat("a", 10),
|
||||
output: "$2a$10$fjtm8BsQ2crym01/piJroenO3oSVUBhSLKaGdTYJ4tG0ePVCrU0G2",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"scrypt", "scrypt$65536$16$2$50"},
|
||||
password: "abcdef",
|
||||
salt: strings.Repeat("a", 10),
|
||||
output: "3b571d0c07c62d42b7bad3dbf18fb0cd67d4d8cd4ad4c6928e1090e5b2a4a84437c6fd2627d897c0e7e65025ca62b67a0002",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"argon2", "argon2$2$65536$8$50"},
|
||||
password: "abcdef",
|
||||
salt: strings.Repeat("a", 10),
|
||||
output: "551f089f570f989975b6f7c6a8ff3cf89bc486dd7bbe87ed4d80ad4362f8ee599ec8dda78dac196301b98456402bcda775dc",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"pbkdf2", "pbkdf2$10000$50"},
|
||||
password: "abcdef",
|
||||
salt: strings.Repeat("a", 10),
|
||||
output: "ab48d5471b7e6ed42d10001db88c852ff7303c788e49da5c3c7b63d5adf96360303724b74b679223a3dea8a242d10abb1913",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"bcrypt", "bcrypt$10"},
|
||||
password: "abcdef",
|
||||
salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
|
||||
output: "$2a$10$qhgm32w9ZpqLygugWJsLjey8xRGcaq9iXAfmCeNBXxddgyoaOC3Gq",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"scrypt", "scrypt$65536$16$2$50"},
|
||||
password: "abcdef",
|
||||
salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
|
||||
output: "25fe5f66b43fa4eb7b6717905317cd2223cf841092dc8e0a1e8c75720ad4846cb5d9387303e14bc3c69faa3b1c51ef4b7de1",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"argon2", "argon2$2$65536$8$50"},
|
||||
password: "abcdef",
|
||||
salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
|
||||
output: "9c287db63a91d18bb1414b703216da4fc431387c1ae7c8acdb280222f11f0929831055dbfd5126a3b48566692e83ec750d2a",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"pbkdf2", "pbkdf2$10000$50"},
|
||||
password: "abcdef",
|
||||
salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
|
||||
output: "45d6cdc843d65cf0eda7b90ab41435762a282f7df013477a1c5b212ba81dbdca2edf1ecc4b5cb05956bb9e0c37ab29315d78",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"pbkdf2$320000$50"},
|
||||
password: "abcdef",
|
||||
salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}),
|
||||
output: "84e233114499e8721da80e85568e5b7b5900b3e49a30845fcda9d1e1756da4547d70f8740ac2b4a5d82f88cebcd27f21bfe2",
|
||||
shouldfail: false,
|
||||
},
|
||||
{
|
||||
algorithms: []string{"pbkdf2", "pbkdf2$10000$50"},
|
||||
password: "abcdef",
|
||||
salt: "",
|
||||
output: "",
|
||||
shouldfail: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Ensure that the current code will correctly verify against the test vectors.
|
||||
func TestVectors(t *testing.T) {
|
||||
for i, vector := range vectors {
|
||||
for _, algorithm := range vector.algorithms {
|
||||
t.Run(strconv.Itoa(i)+": "+algorithm, func(t *testing.T) {
|
||||
pa := Parse(algorithm)
|
||||
assert.Equal(t, !vector.shouldfail, pa.VerifyPassword(vector.password, vector.output, vector.salt))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MustRegister("pbkdf2", NewPBKDF2Hasher)
|
||||
}
|
||||
|
||||
// PBKDF2Hasher implements PasswordHasher
|
||||
// and uses the PBKDF2 key derivation function.
|
||||
type PBKDF2Hasher struct {
|
||||
iter, keyLen int
|
||||
}
|
||||
|
||||
// HashWithSaltBytes a provided password and salt
|
||||
func (hasher *PBKDF2Hasher) HashWithSaltBytes(password string, salt []byte) string {
|
||||
if hasher == nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(pbkdf2.Key([]byte(password), salt, hasher.iter, hasher.keyLen, sha256.New))
|
||||
}
|
||||
|
||||
// NewPBKDF2Hasher is a factory method to create an PBKDF2Hasher
|
||||
// config should be either empty or of the form:
|
||||
// "<iter>$<keyLen>", where <x> is the string representation
|
||||
// of an integer
|
||||
func NewPBKDF2Hasher(config string) *PBKDF2Hasher {
|
||||
// This default configuration uses the following parameters:
|
||||
// iter=10000, keyLen=50.
|
||||
// This matches the original configuration for `pbkdf2` prior to storing parameters
|
||||
// in the database.
|
||||
// THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK
|
||||
hasher := &PBKDF2Hasher{
|
||||
iter: 10_000,
|
||||
keyLen: 50,
|
||||
}
|
||||
|
||||
if config == "" {
|
||||
return hasher
|
||||
}
|
||||
|
||||
vals := strings.SplitN(config, "$", 2)
|
||||
if len(vals) != 2 {
|
||||
log.Error("invalid pbkdf2 hash spec %s", config)
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
hasher.iter, err = parseIntParam(vals[0], "iter", "pbkdf2", config, nil)
|
||||
hasher.keyLen, err = parseIntParam(vals[1], "keyLen", "pbkdf2", config, err)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return hasher
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MustRegister("scrypt", NewScryptHasher)
|
||||
}
|
||||
|
||||
// ScryptHasher implements PasswordHasher
|
||||
// and uses the scrypt key derivation function.
|
||||
type ScryptHasher struct {
|
||||
n, r, p, keyLen int
|
||||
}
|
||||
|
||||
// HashWithSaltBytes a provided password and salt
|
||||
func (hasher *ScryptHasher) HashWithSaltBytes(password string, salt []byte) string {
|
||||
if hasher == nil {
|
||||
return ""
|
||||
}
|
||||
hashedPassword, _ := scrypt.Key([]byte(password), salt, hasher.n, hasher.r, hasher.p, hasher.keyLen)
|
||||
return hex.EncodeToString(hashedPassword)
|
||||
}
|
||||
|
||||
// NewScryptHasher is a factory method to create an ScryptHasher
|
||||
// The provided config should be either empty or of the form:
|
||||
// "<n>$<r>$<p>$<keyLen>", where <x> is the string representation
|
||||
// of an integer
|
||||
func NewScryptHasher(config string) *ScryptHasher {
|
||||
// This matches the original configuration for `scrypt` prior to storing hash parameters
|
||||
// in the database.
|
||||
// THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK
|
||||
hasher := &ScryptHasher{
|
||||
n: 1 << 16,
|
||||
r: 16,
|
||||
p: 2, // 2 passes through memory - this default config will use 128MiB in total.
|
||||
keyLen: 50,
|
||||
}
|
||||
|
||||
if config == "" {
|
||||
return hasher
|
||||
}
|
||||
|
||||
vals := strings.SplitN(config, "$", 4)
|
||||
if len(vals) != 4 {
|
||||
log.Error("invalid scrypt hash spec %s", config)
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
hasher.n, err = parseIntParam(vals[0], "n", "scrypt", config, nil)
|
||||
hasher.r, err = parseIntParam(vals[1], "r", "scrypt", config, err)
|
||||
hasher.p, err = parseIntParam(vals[2], "p", "scrypt", config, err)
|
||||
hasher.keyLen, err = parseIntParam(vals[3], "keyLen", "scrypt", config, err)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return hasher
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
// DefaultHashAlgorithmName represents the default value of PASSWORD_HASH_ALGO
|
||||
// configured in app.ini.
|
||||
//
|
||||
// It is NOT the same and does NOT map to the defaultEmptyHashAlgorithmSpecification.
|
||||
//
|
||||
// It will be dealiased as per aliasAlgorithmNames whereas
|
||||
// defaultEmptyHashAlgorithmSpecification does not undergo dealiasing.
|
||||
const DefaultHashAlgorithmName = "pbkdf2"
|
||||
|
||||
var DefaultHashAlgorithm *PasswordHashAlgorithm
|
||||
|
||||
// aliasAlgorithmNames provides a mapping between the value of PASSWORD_HASH_ALGO
|
||||
// configured in the app.ini and the parameters used within the hashers internally.
|
||||
//
|
||||
// If it is necessary to change the default parameters for any hasher in future you
|
||||
// should change these values and not those in argon2.go etc.
|
||||
var aliasAlgorithmNames = map[string]string{
|
||||
"argon2": "argon2$2$65536$8$50",
|
||||
"bcrypt": "bcrypt$10",
|
||||
"scrypt": "scrypt$65536$16$2$50",
|
||||
"pbkdf2": "pbkdf2_v2", // pbkdf2 should default to pbkdf2_v2
|
||||
"pbkdf2_v1": "pbkdf2$10000$50",
|
||||
// The latest PBKDF2 password algorithm is used as the default since it doesn't
|
||||
// use a lot of memory and is safer to use on less powerful devices.
|
||||
"pbkdf2_v2": "pbkdf2$50000$50",
|
||||
// The pbkdf2_hi password algorithm is offered as a stronger alternative to the
|
||||
// slightly improved pbkdf2_v2 algorithm
|
||||
"pbkdf2_hi": "pbkdf2$320000$50",
|
||||
}
|
||||
|
||||
var RecommendedHashAlgorithms = []string{
|
||||
"pbkdf2",
|
||||
"argon2",
|
||||
"bcrypt",
|
||||
"scrypt",
|
||||
"pbkdf2_hi",
|
||||
}
|
||||
|
||||
// hashAlgorithmToSpec converts an algorithm name or a specification to a full algorithm specification
|
||||
func hashAlgorithmToSpec(algorithmName string) string {
|
||||
if algorithmName == "" {
|
||||
algorithmName = DefaultHashAlgorithmName
|
||||
}
|
||||
alias, has := aliasAlgorithmNames[algorithmName]
|
||||
for has {
|
||||
algorithmName = alias
|
||||
alias, has = aliasAlgorithmNames[algorithmName]
|
||||
}
|
||||
return algorithmName
|
||||
}
|
||||
|
||||
// SetDefaultPasswordHashAlgorithm will take a provided algorithmName and de-alias it to
|
||||
// a complete algorithm specification.
|
||||
func SetDefaultPasswordHashAlgorithm(algorithmName string) (string, *PasswordHashAlgorithm) {
|
||||
algoSpec := hashAlgorithmToSpec(algorithmName)
|
||||
// now we get a full specification, e.g. pbkdf2$50000$50 rather than pbdkf2
|
||||
DefaultHashAlgorithm = Parse(algoSpec)
|
||||
return algoSpec, DefaultHashAlgorithm
|
||||
}
|
||||
|
||||
// ConfigHashAlgorithm will try to find a "recommended algorithm name" defined by RecommendedHashAlgorithms for config
|
||||
// This function is not fast and is only used for the installation page
|
||||
func ConfigHashAlgorithm(algorithm string) string {
|
||||
algorithm = hashAlgorithmToSpec(algorithm)
|
||||
for _, recommAlgo := range RecommendedHashAlgorithms {
|
||||
if algorithm == hashAlgorithmToSpec(recommAlgo) {
|
||||
return recommAlgo
|
||||
}
|
||||
}
|
||||
return algorithm
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCheckSettingPasswordHashAlgorithm(t *testing.T) {
|
||||
t.Run("pbkdf2 is pbkdf2_v2", func(t *testing.T) {
|
||||
pbkdf2v2Config, pbkdf2v2Algo := SetDefaultPasswordHashAlgorithm("pbkdf2_v2")
|
||||
pbkdf2Config, pbkdf2Algo := SetDefaultPasswordHashAlgorithm("pbkdf2")
|
||||
|
||||
assert.Equal(t, pbkdf2v2Config, pbkdf2Config)
|
||||
assert.Equal(t, pbkdf2v2Algo.Specification, pbkdf2Algo.Specification)
|
||||
})
|
||||
|
||||
for a, b := range aliasAlgorithmNames {
|
||||
t.Run(a+"="+b, func(t *testing.T) {
|
||||
aConfig, aAlgo := SetDefaultPasswordHashAlgorithm(a)
|
||||
bConfig, bAlgo := SetDefaultPasswordHashAlgorithm(b)
|
||||
|
||||
assert.Equal(t, bConfig, aConfig)
|
||||
assert.Equal(t, aAlgo.Specification, bAlgo.Specification)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("pbkdf2_v2 is the default when default password hash algorithm is empty", func(t *testing.T) {
|
||||
emptyConfig, emptyAlgo := SetDefaultPasswordHashAlgorithm("")
|
||||
pbkdf2v2Config, pbkdf2v2Algo := SetDefaultPasswordHashAlgorithm("pbkdf2_v2")
|
||||
|
||||
assert.Equal(t, pbkdf2v2Config, emptyConfig)
|
||||
assert.Equal(t, pbkdf2v2Algo.Specification, emptyAlgo.Specification)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package password
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"html/template"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/translation"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrComplexity = errors.New("password not complex enough")
|
||||
ErrMinLength = errors.New("password not long enough")
|
||||
)
|
||||
|
||||
// complexity contains information about a particular kind of password complexity
|
||||
type complexity struct {
|
||||
ValidChars string
|
||||
TrNameOne string
|
||||
}
|
||||
|
||||
var (
|
||||
matchComplexityOnce sync.Once
|
||||
validChars string
|
||||
requiredList []complexity
|
||||
|
||||
charComplexities = map[string]complexity{
|
||||
"lower": {
|
||||
`abcdefghijklmnopqrstuvwxyz`,
|
||||
"form.password_lowercase_one",
|
||||
},
|
||||
"upper": {
|
||||
`ABCDEFGHIJKLMNOPQRSTUVWXYZ`,
|
||||
"form.password_uppercase_one",
|
||||
},
|
||||
"digit": {
|
||||
`0123456789`,
|
||||
"form.password_digit_one",
|
||||
},
|
||||
"spec": {
|
||||
` !"#$%&'()*+,-./:;<=>?@[\]^_{|}~` + "`",
|
||||
"form.password_special_one",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// NewComplexity for preparation
|
||||
func NewComplexity() {
|
||||
matchComplexityOnce.Do(func() {
|
||||
setupComplexity(setting.PasswordComplexity)
|
||||
})
|
||||
}
|
||||
|
||||
func setupComplexity(values []string) {
|
||||
if len(values) != 1 || values[0] != "off" {
|
||||
for _, val := range values {
|
||||
if complexity, ok := charComplexities[val]; ok {
|
||||
validChars += complexity.ValidChars
|
||||
requiredList = append(requiredList, complexity)
|
||||
}
|
||||
}
|
||||
if len(requiredList) == 0 {
|
||||
// No valid character classes found; use all classes as default
|
||||
for _, complexity := range charComplexities {
|
||||
validChars += complexity.ValidChars
|
||||
requiredList = append(requiredList, complexity)
|
||||
}
|
||||
}
|
||||
}
|
||||
if validChars == "" {
|
||||
// No complexities to check; provide a sensible default for password generation
|
||||
validChars = charComplexities["lower"].ValidChars + charComplexities["upper"].ValidChars + charComplexities["digit"].ValidChars
|
||||
}
|
||||
}
|
||||
|
||||
// IsComplexEnough return True if password meets complexity settings
|
||||
func IsComplexEnough(pwd string) bool {
|
||||
NewComplexity()
|
||||
if len(validChars) > 0 {
|
||||
for _, req := range requiredList {
|
||||
if !strings.ContainsAny(req.ValidChars, pwd) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Generate a random password
|
||||
func Generate(n int) (string, error) {
|
||||
NewComplexity()
|
||||
buffer := make([]byte, n)
|
||||
maxInt := big.NewInt(int64(len(validChars)))
|
||||
for {
|
||||
for j := range n {
|
||||
rnd, err := rand.Int(rand.Reader, maxInt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
buffer[j] = validChars[rnd.Int64()]
|
||||
}
|
||||
|
||||
if err := IsPwned(context.Background(), string(buffer)); err != nil {
|
||||
if errors.Is(err, ErrIsPwned) {
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if IsComplexEnough(string(buffer)) && string(buffer[0]) != " " && string(buffer[n-1]) != " " {
|
||||
return string(buffer), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BuildComplexityError builds the error message when password complexity checks fail
|
||||
func BuildComplexityError(locale translation.Locale) template.HTML {
|
||||
var buffer bytes.Buffer
|
||||
buffer.WriteString(locale.TrString("form.password_complexity"))
|
||||
buffer.WriteString("<ul>")
|
||||
for _, c := range requiredList {
|
||||
buffer.WriteString("<li>")
|
||||
buffer.WriteString(locale.TrString(c.TrNameOne))
|
||||
buffer.WriteString("</li>")
|
||||
}
|
||||
buffer.WriteString("</ul>")
|
||||
return template.HTML(buffer.String())
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package password
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComplexity_IsComplexEnough(t *testing.T) {
|
||||
matchComplexityOnce.Do(func() {})
|
||||
|
||||
testlist := []struct {
|
||||
complexity []string
|
||||
truevalues []string
|
||||
falsevalues []string
|
||||
}{
|
||||
{[]string{"off"}, []string{"1", "-", "a", "A", "ñ", "日本語"}, []string{}},
|
||||
{[]string{"lower"}, []string{"abc", "abc!"}, []string{"ABC", "123", "=!$", ""}},
|
||||
{[]string{"upper"}, []string{"ABC"}, []string{"abc", "123", "=!$", "abc!", ""}},
|
||||
{[]string{"digit"}, []string{"123"}, []string{"abc", "ABC", "=!$", "abc!", ""}},
|
||||
{[]string{"spec"}, []string{"=!$", "abc!"}, []string{"abc", "ABC", "123", ""}},
|
||||
{[]string{"off"}, []string{"abc", "ABC", "123", "=!$", "abc!", ""}, nil},
|
||||
{[]string{"lower", "spec"}, []string{"abc!"}, []string{"abc", "ABC", "123", "=!$", "abcABC123", ""}},
|
||||
{[]string{"lower", "upper", "digit"}, []string{"abcABC123"}, []string{"abc", "ABC", "123", "=!$", "abc!", ""}},
|
||||
{[]string{""}, []string{"abC=1", "abc!9D"}, []string{"ABC", "123", "=!$", ""}},
|
||||
}
|
||||
|
||||
for _, test := range testlist {
|
||||
testComplextity(test.complexity)
|
||||
for _, val := range test.truevalues {
|
||||
assert.True(t, IsComplexEnough(val))
|
||||
}
|
||||
for _, val := range test.falsevalues {
|
||||
assert.False(t, IsComplexEnough(val))
|
||||
}
|
||||
}
|
||||
|
||||
// Remove settings for other tests
|
||||
testComplextity([]string{"off"})
|
||||
}
|
||||
|
||||
func TestComplexity_Generate(t *testing.T) {
|
||||
matchComplexityOnce.Do(func() {})
|
||||
|
||||
const maxCount = 50
|
||||
const pwdLen = 50
|
||||
|
||||
test := func(t *testing.T, modes []string) {
|
||||
testComplextity(modes)
|
||||
for range maxCount {
|
||||
pwd, err := Generate(pwdLen)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, pwd, pwdLen)
|
||||
assert.True(t, IsComplexEnough(pwd), "Failed complexities with modes %+v for generated: %s", modes, pwd)
|
||||
}
|
||||
}
|
||||
|
||||
test(t, []string{"lower"})
|
||||
test(t, []string{"upper"})
|
||||
test(t, []string{"lower", "upper", "spec"})
|
||||
test(t, []string{"off"})
|
||||
test(t, []string{""})
|
||||
|
||||
// Remove settings for other tests
|
||||
testComplextity([]string{"off"})
|
||||
}
|
||||
|
||||
func testComplextity(values []string) {
|
||||
// Cleanup previous values
|
||||
validChars = ""
|
||||
requiredList = make([]complexity, 0, len(values))
|
||||
setupComplexity(values)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package password
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/modules/auth/password/pwn"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
var ErrIsPwned = errors.New("password has been pwned")
|
||||
|
||||
type ErrIsPwnedRequest struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func IsErrIsPwnedRequest(err error) bool {
|
||||
_, ok := err.(ErrIsPwnedRequest)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrIsPwnedRequest) Error() string {
|
||||
return fmt.Sprintf("using Have-I-Been-Pwned service failed: %v", err.err)
|
||||
}
|
||||
|
||||
func (err ErrIsPwnedRequest) Unwrap() error {
|
||||
return err.err
|
||||
}
|
||||
|
||||
// IsPwned checks whether a password has been pwned
|
||||
// If a password has not been pwned, no error is returned.
|
||||
func IsPwned(ctx context.Context, password string) error {
|
||||
if !setting.PasswordCheckPwn {
|
||||
return nil
|
||||
}
|
||||
|
||||
client := pwn.New(pwn.WithContext(ctx))
|
||||
count, err := client.CheckPassword(password, true)
|
||||
if err != nil {
|
||||
return ErrIsPwnedRequest{err}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return ErrIsPwned
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pwn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
const passwordURL = "https://api.pwnedpasswords.com/range/"
|
||||
|
||||
// ErrEmptyPassword is an empty password error
|
||||
var ErrEmptyPassword = errors.New("password cannot be empty")
|
||||
|
||||
// Client is a HaveIBeenPwned client
|
||||
type Client struct {
|
||||
ctx context.Context
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New returns a new HaveIBeenPwned Client
|
||||
func New(options ...ClientOption) *Client {
|
||||
client := &Client{
|
||||
ctx: context.Background(),
|
||||
http: http.DefaultClient,
|
||||
}
|
||||
|
||||
for _, opt := range options {
|
||||
opt(client)
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// ClientOption is a way to modify a new Client
|
||||
type ClientOption func(*Client)
|
||||
|
||||
// WithHTTP will set the http.Client of a Client
|
||||
func WithHTTP(httpClient *http.Client) func(pwnClient *Client) {
|
||||
return func(pwnClient *Client) {
|
||||
pwnClient.http = httpClient
|
||||
}
|
||||
}
|
||||
|
||||
// WithContext will set the context.Context of a Client
|
||||
func WithContext(ctx context.Context) func(pwnClient *Client) {
|
||||
return func(pwnClient *Client) {
|
||||
pwnClient.ctx = ctx
|
||||
}
|
||||
}
|
||||
|
||||
func newRequest(ctx context.Context, method, url string, body io.ReadCloser) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Add("User-Agent", "Gitea "+setting.AppVer)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// CheckPassword returns the number of times a password has been compromised
|
||||
// Adding padding will make requests more secure, however is also slower
|
||||
// because artificial responses will be added to the response
|
||||
// For more information, see https://www.troyhunt.com/enhancing-pwned-passwords-privacy-with-padding/
|
||||
func (c *Client) CheckPassword(pw string, padding bool) (int64, error) {
|
||||
if pw == "" {
|
||||
return -1, ErrEmptyPassword
|
||||
}
|
||||
|
||||
sha := sha1.New()
|
||||
sha.Write([]byte(pw))
|
||||
enc := hex.EncodeToString(sha.Sum(nil))
|
||||
prefix, suffix := enc[:5], enc[5:]
|
||||
|
||||
req, err := newRequest(c.ctx, http.MethodGet, fmt.Sprintf("%s%s", passwordURL, prefix), nil)
|
||||
if err != nil {
|
||||
return -1, nil
|
||||
}
|
||||
if padding {
|
||||
req.Header.Add("Add-Padding", "true")
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for pair := range strings.SplitSeq(string(body), "\n") {
|
||||
parts := strings.Split(pair, ":")
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(suffix, parts[0]) {
|
||||
count, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pwn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type mockTransport struct{}
|
||||
|
||||
func (mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host != "api.pwnedpasswords.com" {
|
||||
return nil, errors.New("unsupported host")
|
||||
}
|
||||
respMap := map[string]string{
|
||||
"/range/5c1d8": "EAF2F254732680E8AC339B84F3266ECCBB5:1\r\nFC446EB88938834178CB9322C1EE273C2A7:2",
|
||||
"/range/ba189": "FD4CB34F0378BCB15D23F6FFD28F0775C9E:3\r\nFDF342FCD8C3611DAE4D76E8A992A3E4169:4",
|
||||
"/range/a1733": "C4CE0F1F0062B27B9E2F41AF0C08218017C:1\r\nFC446EB88938834178CB9322C1EE273C2A7:2\r\nFE81480327C992FE62065A827429DD1318B:0",
|
||||
"/range/5617b": "FD4CB34F0378BCB15D23F6FFD28F0775C9E:3\r\nFDF342FCD8C3611DAE4D76E8A992A3E4169:4\r\nFE81480327C992FE62065A827429DD1318B:0",
|
||||
"/range/79082": "FDF342FCD8C3611DAE4D76E8A992A3E4169:4\r\nFE81480327C992FE62065A827429DD1318B:0\r\nAFEF386F56EB0B4BE314E07696E5E6E6536:0",
|
||||
}
|
||||
if resp, ok := respMap[req.URL.Path]; ok {
|
||||
return &http.Response{Request: req, Body: io.NopCloser(strings.NewReader(resp))}, nil
|
||||
}
|
||||
return nil, errors.New("unsupported path")
|
||||
}
|
||||
|
||||
func TestPassword(t *testing.T) {
|
||||
client := New(WithHTTP(&http.Client{Transport: mockTransport{}}))
|
||||
|
||||
count, err := client.CheckPassword("", false)
|
||||
assert.ErrorIs(t, err, ErrEmptyPassword, "blank input should return ErrEmptyPassword")
|
||||
assert.EqualValues(t, -1, count)
|
||||
|
||||
count, err = client.CheckPassword("pwned", false)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 1, count)
|
||||
|
||||
count, err = client.CheckPassword("notpwned", false)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 0, count)
|
||||
|
||||
count, err = client.CheckPassword("paddedpwned", true)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 1, count)
|
||||
|
||||
count, err = client.CheckPassword("paddednotpwned", true)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 0, count)
|
||||
|
||||
count, err = client.CheckPassword("paddednotpwnedzero", true)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 0, count)
|
||||
}
|
||||
Reference in New Issue
Block a user