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

feat: Bcrypt algorithm support #1169

Merged
merged 14 commits into from
Apr 1, 2021
28 changes: 24 additions & 4 deletions docs/docs/concepts/credentials/username-email-password.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ during registration and login.
ORY Kratos hashes the password after registration, password reset, and password
change using the [Argon2 Hashing Algorithm](../security.mdx#Argon2), the winner
of the
[Password Hashing Competition (PHC)](https:/P-H-C/phc-winner-argon2).
[Password Hashing Competition (PHC)](https:/P-H-C/phc-winner-argon2),
or Bcrypt.

## Configuration

Expand All @@ -24,8 +25,9 @@ selfservice:
enabled: true
```

in your ORY Kratos configuration. You can configure the Argon2 hasher using the
following options:
in your ORY Kratos configuration.

You can configure the Argon2 hasher using the following options:

```yaml title="path/to/my/kratos/config.yml"
# $ kratos -c path/to/my/kratos/config.yml serve
Expand All @@ -38,6 +40,24 @@ hashers:
key_length: 32
```

By default, Kratos uses Argon2 algorithm for password hashing. Use the following
option to use Bcrypt algorithm:

```yaml title="path/to/my/kratos/config.yml"
hashers:
algorithm: bcrypt
```

Bcrypt algorithm can be configured only by the following `cost` option (default
value is 12):

```yaml title="path/to/my/kratos/config.yml"
# $ kratos -c path/to/my/kratos/config.yml serve
hashers:
bcrypt:
cost: 12
```

To determine the ideal parameters, head over to the
[setup guide](../../guides/setting-up-password-hashing-parameters).

Expand Down Expand Up @@ -337,7 +357,7 @@ credentials:
- [email protected]
- johndoe123
config:
hashed_password: ... # this would be `argon2(my-secret-password)`
hashed_password: ... # this would be a hash of `my-secret-password` string
```

Because credential identifiers need to be unique, no other identity can be
Expand Down
21 changes: 21 additions & 0 deletions driver/config/.schema/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,13 @@
"title": "Hashing Algorithm Configuration",
"type": "object",
"properties": {
"algorithm": {
"title": "Password hashing algorithm",
"description": "One of the values: argon2, bcrypt",
"type": "string",
"default": "argon2",
"enum": ["argon2", "bcrypt"]
},
seremenko-wish marked this conversation as resolved.
Show resolved Hide resolved
"argon2": {
"title": "Configuration for the Argon2id hasher.",
"type": "object",
Expand All @@ -1320,6 +1327,20 @@
}
},
"additionalProperties": false
},
"bcrypt": {
"title": "Configuration for the Bcrypt hasher.",
"type": "object",
"additionalProperties": false,
"required": ["cost"],
"properties": {
"cost": {
"type": "integer",
"minimum": 12,
"maximum": 31,
"default": 12
}
}
}
},
"additionalProperties": false
Expand Down
27 changes: 27 additions & 0 deletions driver/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const (
DefaultIdentityTraitsSchemaID = "default"
DefaultBrowserReturnURL = "default_browser_return_url"
DefaultSQLiteMemoryDSN = dbal.SQLiteInMemory
DefaultPasswordHashingAlgorithm = "argon2"
UnknownVersion = "unknown version"
ViperKeyDSN = "dsn"
ViperKeyCourierSMTPURL = "courier.smtp.connection_uri"
Expand Down Expand Up @@ -84,18 +85,21 @@ const (
ViperKeySelfServiceVerificationBrowserDefaultReturnTo = "selfservice.flows.verification.after." + DefaultBrowserReturnURL
ViperKeyDefaultIdentitySchemaURL = "identity.default_schema_url"
ViperKeyIdentitySchemas = "identity.schemas"
ViperKeyHasherAlgorithm = "hashers.algorithm"
ViperKeyHasherArgon2ConfigMemory = "hashers.argon2.memory"
ViperKeyHasherArgon2ConfigIterations = "hashers.argon2.iterations"
ViperKeyHasherArgon2ConfigParallelism = "hashers.argon2.parallelism"
ViperKeyHasherArgon2ConfigSaltLength = "hashers.argon2.salt_length"
ViperKeyHasherArgon2ConfigKeyLength = "hashers.argon2.key_length"
ViperKeyHasherBcryptCost = "hashers.bcrypt.cost"
ViperKeyPasswordMaxBreaches = "selfservice.methods.password.config.max_breaches"
ViperKeyIgnoreNetworkErrors = "selfservice.methods.password.config.ignore_network_errors"
ViperKeyVersion = "version"
Argon2DefaultMemory uint32 = 4 * 1024 * 1024
Argon2DefaultIterations uint32 = 4
Argon2DefaultSaltLength uint32 = 16
Argon2DefaultKeyLength uint32 = 32
BcryptDefaultCost uint32 = 12
)

// DefaultSessionCookieName returns the default cookie name for the kratos session.
Expand All @@ -109,6 +113,9 @@ type (
SaltLength uint32 `json:"salt_length"`
KeyLength uint32 `json:"key_length"`
}
Bcrypt struct {
Cost uint32 `json:"cost"`
}
SelfServiceHook struct {
Name string `json:"hook"`
Config json.RawMessage `json:"config"`
Expand Down Expand Up @@ -235,6 +242,14 @@ func (p *Config) HasherArgon2() *Argon2 {
}
}

func (p *Config) HasherBcrypt() *Bcrypt {
// warn about usage of default values and point to the docs
// warning will require https:/ory/viper/issues/19
return &Bcrypt{
Cost: uint32(p.p.IntF(ViperKeyHasherBcryptCost, int(BcryptDefaultCost))),
}
}

func (p *Config) listenOn(key string) string {
fb := 4433
if key == "admin" {
Expand Down Expand Up @@ -732,3 +747,15 @@ func (p *Config) PasswordPolicyConfig() *PasswordPolicy {
IgnoreNetworkErrors: p.p.BoolF(ViperKeyIgnoreNetworkErrors, true),
}
}

func (p *Config) HasherPasswordHashingAlgorithm() string {
configValue := p.p.StringF(ViperKeyHasherAlgorithm, DefaultPasswordHashingAlgorithm)
switch configValue {
case "bcrypt":
return configValue
case "argon2":
seremenko-wish marked this conversation as resolved.
Show resolved Hide resolved
fallthrough
default:
return configValue
}
}
6 changes: 5 additions & 1 deletion driver/registry_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,11 @@ func (m *RegistryDefault) SessionHandler() *session.Handler {

func (m *RegistryDefault) Hasher() hash.Hasher {
if m.passwordHasher == nil {
m.passwordHasher = hash.NewHasherArgon2(m)
if m.c.HasherPasswordHashingAlgorithm() == "bcrypt" {
m.passwordHasher = hash.NewHasherBcrypt(m)
} else {
m.passwordHasher = hash.NewHasherArgon2(m)
}
}
return m.passwordHasher
}
Expand Down
107 changes: 107 additions & 0 deletions hash/hash_comparator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package hash

import (
"context"
"crypto/subtle"
"encoding/base64"
"fmt"
"regexp"
"strings"

"github.com/pkg/errors"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"

"github.com/ory/kratos/driver/config"
)
seremenko-wish marked this conversation as resolved.
Show resolved Hide resolved

var ErrUnknownHashAlgorithm = errors.New("unknown hash algorithm")

func Compare(ctx context.Context, password []byte, hash []byte) error {
if isBcryptHash(hash) {
return CompareBcrypt(ctx, password, hash)
} else if isArgon2idHash(hash) {
return CompareArgon2id(ctx, password, hash)
} else {
return ErrUnknownHashAlgorithm
}
}

func CompareBcrypt(_ context.Context, password []byte, hash []byte) error {
if err := validateBcryptPasswordLength(password); err != nil {
return err
}

err := bcrypt.CompareHashAndPassword(hash, password)
if err != nil {
return err
}

return nil
}

func CompareArgon2id(_ context.Context, password []byte, hash []byte) error {
// Extract the parameters, salt and derived key from the encoded password
// hash.
p, salt, hash, err := decodeArgon2idHash(string(hash))
if err != nil {
return err
}

// Derive the key from the other password using the same parameters.
otherHash := argon2.IDKey([]byte(password), salt, p.Iterations, p.Memory, p.Parallelism, p.KeyLength)

// Check that the contents of the hashed passwords are identical. Note
// that we are using the subtle.ConstantTimeCompare() function for this
// to help prevent timing attacks.
if subtle.ConstantTimeCompare(hash, otherHash) == 1 {
return nil
}
return ErrMismatchedHashAndPassword
}

func isBcryptHash(hash []byte) bool {
res, _ := regexp.Match("^\\$2[abzy]?\\$", hash)
return res
}

func isArgon2idHash(hash []byte) bool {
res, _ := regexp.Match("^\\$argon2id\\$", hash)
seremenko-wish marked this conversation as resolved.
Show resolved Hide resolved
return res
}

func decodeArgon2idHash(encodedHash string) (p *config.Argon2, salt, hash []byte, err error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 {
return nil, nil, nil, ErrInvalidHash
}

var version int
_, err = fmt.Sscanf(parts[2], "v=%d", &version)
if err != nil {
return nil, nil, nil, err
}
if version != argon2.Version {
return nil, nil, nil, ErrIncompatibleVersion
}

p = new(config.Argon2)
_, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.Memory, &p.Iterations, &p.Parallelism)
if err != nil {
return nil, nil, nil, err
}

salt, err = base64.RawStdEncoding.Strict().DecodeString(parts[4])
aeneasr marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, nil, nil, err
}
p.SaltLength = uint32(len(salt))

hash, err = base64.RawStdEncoding.Strict().DecodeString(parts[5])
if err != nil {
return nil, nil, nil, err
}
p.KeyLength = uint32(len(hash))

return p, salt, hash, nil
}
3 changes: 0 additions & 3 deletions hash/hasher.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ import "context"

// Hasher provides methods for generating and comparing password hashes.
type Hasher interface {
// Compare a password to a hash and return nil if they match or an error otherwise.
Compare(ctx context.Context, password []byte, hash []byte) error

// Generate returns a hash derived from the password or an error if the hash method failed.
Generate(ctx context.Context, password []byte) ([]byte, error)
}
Expand Down
58 changes: 0 additions & 58 deletions hash/hasher_argon2.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ import (
"bytes"
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"strings"

"github.com/pkg/errors"
"golang.org/x/crypto/argon2"
Expand Down Expand Up @@ -59,59 +57,3 @@ func (h *Argon2) Generate(ctx context.Context, password []byte) ([]byte, error)

return b.Bytes(), nil
}

func (h *Argon2) Compare(ctx context.Context, password []byte, hash []byte) error {
// Extract the parameters, salt and derived key from the encoded password
// hash.
p, salt, hash, err := decodeHash(string(hash))
if err != nil {
return err
}

// Derive the key from the other password using the same parameters.
otherHash := argon2.IDKey([]byte(password), salt, p.Iterations, p.Memory, p.Parallelism, p.KeyLength)

// Check that the contents of the hashed passwords are identical. Note
// that we are using the subtle.ConstantTimeCompare() function for this
// to help prevent timing attacks.
if subtle.ConstantTimeCompare(hash, otherHash) == 1 {
return nil
}
return ErrMismatchedHashAndPassword
}

func decodeHash(encodedHash string) (p *config.Argon2, salt, hash []byte, err error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 {
return nil, nil, nil, ErrInvalidHash
}

var version int
_, err = fmt.Sscanf(parts[2], "v=%d", &version)
if err != nil {
return nil, nil, nil, err
}
if version != argon2.Version {
return nil, nil, nil, ErrIncompatibleVersion
}

p = new(config.Argon2)
_, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.Memory, &p.Iterations, &p.Parallelism)
if err != nil {
return nil, nil, nil, err
}

salt, err = base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return nil, nil, nil, err
}
p.SaltLength = uint32(len(salt))

hash, err = base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return nil, nil, nil, err
}
p.KeyLength = uint32(len(hash))

return p, salt, hash, nil
}
Loading