diff --git a/docs/docs/concepts/credentials/username-email-password.mdx b/docs/docs/concepts/credentials/username-email-password.mdx index b235912a4eba..876bba3e4ba2 100644 --- a/docs/docs/concepts/credentials/username-email-password.mdx +++ b/docs/docs/concepts/credentials/username-email-password.mdx @@ -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://github.com/P-H-C/phc-winner-argon2). +[Password Hashing Competition (PHC)](https://github.com/P-H-C/phc-winner-argon2), +or Bcrypt. ## Configuration @@ -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 @@ -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). @@ -337,7 +357,7 @@ credentials: - john.doe@example.org - 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 diff --git a/driver/config/.schema/config.schema.json b/driver/config/.schema/config.schema.json index f822be328b90..679a203ddcf5 100644 --- a/driver/config/.schema/config.schema.json +++ b/driver/config/.schema/config.schema.json @@ -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"] + }, "argon2": { "title": "Configuration for the Argon2id hasher.", "type": "object", @@ -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 diff --git a/driver/config/config.go b/driver/config/config.go index ef78c2ca4a64..554f361f7968 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -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" @@ -84,11 +85,13 @@ 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" @@ -96,6 +99,7 @@ const ( Argon2DefaultIterations uint32 = 4 Argon2DefaultSaltLength uint32 = 16 Argon2DefaultKeyLength uint32 = 32 + BcryptDefaultCost uint32 = 12 ) // DefaultSessionCookieName returns the default cookie name for the kratos session. @@ -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"` @@ -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://github.com/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" { @@ -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": + fallthrough + default: + return configValue + } +} diff --git a/driver/registry_default.go b/driver/registry_default.go index 6c18ef5cf285..43869d0c2a9e 100644 --- a/driver/registry_default.go +++ b/driver/registry_default.go @@ -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 } diff --git a/hash/hash_comparator.go b/hash/hash_comparator.go new file mode 100644 index 000000000000..dfe129d1907a --- /dev/null +++ b/hash/hash_comparator.go @@ -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" +) + +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) + 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]) + 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 +} diff --git a/hash/hasher.go b/hash/hasher.go index 8641892e035f..28ca8553866e 100644 --- a/hash/hasher.go +++ b/hash/hasher.go @@ -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) } diff --git a/hash/hasher_argon2.go b/hash/hasher_argon2.go index f3e2505ea7e7..88a40d9cd347 100644 --- a/hash/hasher_argon2.go +++ b/hash/hasher_argon2.go @@ -4,10 +4,8 @@ import ( "bytes" "context" "crypto/rand" - "crypto/subtle" "encoding/base64" "fmt" - "strings" "github.com/pkg/errors" "golang.org/x/crypto/argon2" @@ -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 -} diff --git a/hash/hasher_bcrypt.go b/hash/hasher_bcrypt.go new file mode 100644 index 000000000000..eb0625d2f70c --- /dev/null +++ b/hash/hasher_bcrypt.go @@ -0,0 +1,49 @@ +package hash + +import ( + "context" + + "github.com/ory/kratos/schema" + + "golang.org/x/crypto/bcrypt" + + "github.com/ory/kratos/driver/config" +) + +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 schema.NewPasswordPolicyViolationError( + "#/password", + "passwords are limited to a maximum length of 72 characters", + ) + } + return nil +} diff --git a/hash/hasher_test.go b/hash/hasher_test.go index 45856f9ade87..b74890cfb5cf 100644 --- a/hash/hasher_test.go +++ b/hash/hasher_test.go @@ -20,7 +20,7 @@ func mkpw(t *testing.T, length int) []byte { return pw } -func TestHasher(t *testing.T) { +func TestArgonHasher(t *testing.T) { for k, pw := range [][]byte{ mkpw(t, 8), mkpw(t, 16), @@ -39,14 +39,113 @@ func TestHasher(t *testing.T) { assert.NotEqual(t, pw, hs) t.Logf("hash: %s", hs) - require.NoError(t, h.Compare(context.Background(), pw, hs)) + require.NoError(t, hash.CompareArgon2id(context.Background(), pw, hs)) mod := make([]byte, len(pw)) copy(mod, pw) mod[len(pw)-1] = ^pw[len(pw)-1] - require.Error(t, h.Compare(context.Background(), mod, hs)) + require.Error(t, hash.CompareArgon2id(context.Background(), mod, hs)) }) } }) } } + +func TestBcryptHasherGeneratesErrorWhenPasswordIsLong(t *testing.T) { + _, reg := internal.NewFastRegistryWithMocks(t) + hasher := hash.NewHasherBcrypt(reg) + + password := mkpw(t, 73) + res, err := hasher.Generate(context.Background(), password) + + assert.Error(t, err, "password is too long") + assert.Nil(t, res) +} + +func TestBcryptHasherGeneratesHash(t *testing.T) { + for k, pw := range [][]byte{ + mkpw(t, 8), + mkpw(t, 16), + mkpw(t, 32), + mkpw(t, 64), + mkpw(t, 72), + } { + t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { + _, reg := internal.NewFastRegistryWithMocks(t) + hasher := hash.NewHasherBcrypt(reg) + res, err := hasher.Generate(context.Background(), pw) + + assert.Nil(t, err) + + // Valid format: $2a$12$[22 character salt][31 character hash] + assert.Equal(t, 60, len(string(res)), "invalid bcrypt hash length") + assert.Equal(t, "$2a$12$", string(res)[:7], "invalid bcrypt identifier") + }) + } +} + +func TestComparatorBcryptFailsWhenPasswordIsTooLong(t *testing.T) { + password := mkpw(t, 73) + err := hash.CompareBcrypt(context.Background(), password, []byte("hash")) + + assert.Error(t, err, "password is too long") +} + +func TestComparatorBcryptSuccess(t *testing.T) { + for k, pw := range [][]byte{ + mkpw(t, 8), + mkpw(t, 16), + mkpw(t, 32), + mkpw(t, 64), + mkpw(t, 72), + } { + t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { + _, reg := internal.NewFastRegistryWithMocks(t) + hasher := hash.NewHasherBcrypt(reg) + + hs, err := hasher.Generate(context.Background(), pw) + + assert.Nil(t, err) + + err = hash.CompareBcrypt(context.Background(), pw, hs) + assert.Nil(t, err, "hash validation fails") + }) + } +} + +func TestComparatorBcryptFail(t *testing.T) { + for k, pw := range [][]byte{ + mkpw(t, 8), + mkpw(t, 16), + mkpw(t, 32), + mkpw(t, 64), + mkpw(t, 72), + } { + t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { + mod := make([]byte, len(pw)) + copy(mod, pw) + mod[len(pw)-1] = ^pw[len(pw)-1] + + err := hash.CompareBcrypt(context.Background(), pw, mod) + assert.Error(t, err) + }) + } +} + +func TestCompare(t *testing.T) { + assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$2a$12$o6hx.Wog/wvFSkT/Bp/6DOxCtLRTDj7lm9on9suF/WaCGNVHbkfL6"))) + assert.Nil(t, hash.CompareBcrypt(context.Background(), []byte("test"), []byte("$2a$12$o6hx.Wog/wvFSkT/Bp/6DOxCtLRTDj7lm9on9suF/WaCGNVHbkfL6"))) + assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$2a$12$o6hx.Wog/wvFSkT/Bp/6DOxCtLRTDj7lm9on9suF/WaCGNVHbkfL7"))) + + assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$2a$15$GRvRO2nrpYTEuPQX6AieaOlZ4.7nMGsXpt.QWMev1zrP86JNspZbO"))) + assert.Nil(t, hash.CompareBcrypt(context.Background(), []byte("test"), []byte("$2a$15$GRvRO2nrpYTEuPQX6AieaOlZ4.7nMGsXpt.QWMev1zrP86JNspZbO"))) + assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$2a$15$GRvRO2nrpYTEuPQX6AieaOlZ4.7nMGsXpt.QWMev1zrP86JNspZb1"))) + + assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"))) + assert.Nil(t, hash.CompareArgon2id(context.Background(), []byte("test"), []byte("$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"))) + assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRN2"))) + + assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$argon2id$v=19$m=32,t=5,p=4$cm94YnRVOW5jZzFzcVE4bQ$fBxypOL0nP/zdPE71JtAV71i487LbX3fJI5PoTN6Lp4"))) + assert.Nil(t, hash.CompareArgon2id(context.Background(), []byte("test"), []byte("$argon2id$v=19$m=32,t=5,p=4$cm94YnRVOW5jZzFzcVE4bQ$fBxypOL0nP/zdPE71JtAV71i487LbX3fJI5PoTN6Lp4"))) + assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$argon2id$v=19$m=32,t=5,p=4$cm94YnRVOW5jZzFzcVE4bQ$fBxypOL0nP/zdPE71JtAV71i487LbX3fJI5PoTN6Lp5"))) +} diff --git a/selfservice/strategy/password/login.go b/selfservice/strategy/password/login.go index bd3f2f034d75..fee57b5a4d2d 100644 --- a/selfservice/strategy/password/login.go +++ b/selfservice/strategy/password/login.go @@ -5,6 +5,8 @@ import ( "encoding/json" "net/http" + "github.com/ory/kratos/hash" + "github.com/julienschmidt/httprouter" "github.com/pkg/errors" @@ -146,7 +148,7 @@ func (s *Strategy) handleLogin(w http.ResponseWriter, r *http.Request, _ httprou return } - if err := s.d.Hasher().Compare(r.Context(), []byte(p.Password), []byte(o.HashedPassword)); err != nil { + if err := hash.Compare(r.Context(), []byte(p.Password), []byte(o.HashedPassword)); err != nil { s.handleLoginError(w, r, ar, &p, errors.WithStack(schema.NewInvalidCredentialsError())) return }