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
6 changes: 6 additions & 0 deletions driver/config/.schema/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,12 @@
"title": "Hashing Algorithm Configuration",
"type": "object",
"properties": {
"algorithm": {
"title": "Password hashing algorithm",
"description": "One of the values: argon2, bcrypt",
"type": "string",
"default": "argon2"
seremenko-wish marked this conversation as resolved.
Show resolved Hide resolved
},
seremenko-wish marked this conversation as resolved.
Show resolved Hide resolved
"argon2": {
"title": "Configuration for the Argon2id hasher.",
"type": "object",
Expand Down
28 changes: 28 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,15 @@ 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 +748,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"
"github.com/ory/kratos/driver/config"
"github.com/pkg/errors"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
"regexp"
"strings"
)
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
59 changes: 0 additions & 59 deletions hash/hasher_argon2.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +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 +56,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
}
43 changes: 43 additions & 0 deletions hash/hasher_bcrypt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package hash

import (
"context"
"github.com/ory/kratos/driver/config"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)

type Bcrypt struct {
c BcryptConfiguration
}

type BcryptConfiguration interface {
config.Provider
}

func NewHasherBcrypt(c BcryptConfiguration) *Bcrypt {
return &Bcrypt{c: c}
}

func (h *Bcrypt) Generate(ctx context.Context, password []byte) ([]byte, error) {
if err := validateBcryptPasswordLength(password); err != nil {
return nil, err
}

hash, err := bcrypt.GenerateFromPassword(password, int(h.c.Config(ctx).HasherBcrypt().Cost))
if err != nil {
return nil, err
}

return hash, nil
}

func validateBcryptPasswordLength(password []byte) error {
// Bcrypt truncates the password to the first 72 bytes, following the OpenBSD implementation,
// so if password is longer than 72 bytes, function returns an error
// See https://en.wikipedia.org/wiki/Bcrypt#User_input
if len(password) > 72 {
return errors.New("passwords are limited to a maximum length of 72 characters")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error will be passed down to the UI I guess. Please add it to the messages to allow translations and mapping to other text: https:/ory/kratos/blob/1940a679eb6b695e22cb939fb0d8d85cebb82a1e/text/message_registration.go

I guess you would then in the flow check for this error you return here similar to

a.Messages.Add(text.NewErrorValidationRegistrationFlowExpired(e.ago))

But maybe @aeneasr can give better pointers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, it would be an error like this one (actually we can just add it do that file for now): https:/ory/kratos/blob/master/schema/errors.go#L59-L70

}
return nil
}
Loading