From 17dbb9660e1bb84bbf92dfad1a5e5547c1bb9069 Mon Sep 17 00:00:00 2001 From: Landon Pattison <67596936+LandonPattison@users.noreply.github.com> Date: Thu, 8 Sep 2022 03:43:53 -0500 Subject: [PATCH] feat: allow importing scrypt hashing algorithm (#2689) It is now possible to import scrypt-hashed passwords. See #2422 --- hash/hash_comparator.go | 61 +++++++++++++++++++ hash/hasher_scrypt.go | 9 +++ hash/hasher_test.go | 11 ++++ ..._to_import_users-with_scrypt_password.json | 20 ++++++ identity/handler_import.go | 2 +- identity/handler_test.go | 12 ++++ 6 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 hash/hasher_scrypt.go create mode 100644 identity/.snapshots/TestHandler-case=should_be_able_to_import_users-with_scrypt_password.json diff --git a/hash/hash_comparator.go b/hash/hash_comparator.go index dc4ab0914b97..7514dc7c999c 100644 --- a/hash/hash_comparator.go +++ b/hash/hash_comparator.go @@ -12,6 +12,7 @@ import ( "golang.org/x/crypto/argon2" "golang.org/x/crypto/bcrypt" "golang.org/x/crypto/pbkdf2" + "golang.org/x/crypto/scrypt" "github.com/ory/kratos/driver/config" ) @@ -28,6 +29,8 @@ func Compare(ctx context.Context, password []byte, hash []byte) error { return CompareArgon2i(ctx, password, hash) case IsPbkdf2Hash(hash): return ComparePbkdf2(ctx, password, hash) + case IsScryptHash(hash): + return CompareScrypt(ctx, password, hash) default: return errors.WithStack(ErrUnknownHashAlgorithm) } @@ -106,11 +109,35 @@ func ComparePbkdf2(_ context.Context, password []byte, hash []byte) error { return errors.WithStack(ErrMismatchedHashAndPassword) } +func CompareScrypt(_ context.Context, password []byte, hash []byte) error { + // Extract the parameters, salt and derived key from the encoded password + // hash. + p, salt, hash, err := decodeScryptHash(string(hash)) + if err != nil { + return err + } + + // Derive the key from the other password using the same parameters. + otherHash, err := scrypt.Key(password, salt, int(p.Cost), int(p.Block), int(p.Parrellization), int(p.KeyLength)) + if err != nil { + return errors.WithStack(err) + } + + // 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 errors.WithStack(ErrMismatchedHashAndPassword) +} + var ( isBcryptHash = regexp.MustCompile(`^\$2[abzy]?\$`) isArgon2idHash = regexp.MustCompile(`^\$argon2id\$`) isArgon2iHash = regexp.MustCompile(`^\$argon2i\$`) isPbkdf2Hash = regexp.MustCompile(`^\$pbkdf2-sha[0-9]{1,3}\$`) + isScryptHash = regexp.MustCompile(`^\$scrypt\$`) ) func IsBcryptHash(hash []byte) bool { @@ -129,6 +156,10 @@ func IsPbkdf2Hash(hash []byte) bool { return isPbkdf2Hash.Match(hash) } +func IsScryptHash(hash []byte) bool { + return isScryptHash.Match(hash) +} + func decodeArgon2idHash(encodedHash string) (p *config.Argon2, salt, hash []byte, err error) { parts := strings.Split(encodedHash, "$") if len(parts) != 6 { @@ -199,3 +230,33 @@ func decodePbkdf2Hash(encodedHash string) (p *Pbkdf2, salt, hash []byte, err err return p, salt, hash, nil } + +// decodeScryptHash decodes Scrypt encoded password hash. +// format: $scrypt$ln=,r=,p=$$ +func decodeScryptHash(encodedHash string) (p *Scrypt, salt, hash []byte, err error) { + parts := strings.Split(encodedHash, "$") + if len(parts) != 5 { + return nil, nil, nil, ErrInvalidHash + } + + p = new(Scrypt) + + _, err = fmt.Sscanf(parts[2], "ln=%d,r=%d,p=%d", &p.Cost, &p.Block, &p.Parrellization) + if err != nil { + return nil, nil, nil, err + } + + salt, err = base64.StdEncoding.Strict().DecodeString(parts[3]) + if err != nil { + return nil, nil, nil, err + } + p.SaltLength = uint32(len(salt)) + + hash, err = base64.StdEncoding.Strict().DecodeString(parts[4]) + if err != nil { + return nil, nil, nil, err + } + p.KeyLength = uint32(len(hash)) + + return p, salt, hash, nil +} diff --git a/hash/hasher_scrypt.go b/hash/hasher_scrypt.go new file mode 100644 index 000000000000..9075c6b9e07d --- /dev/null +++ b/hash/hasher_scrypt.go @@ -0,0 +1,9 @@ +package hash + +type Scrypt struct { + Cost uint32 + Block uint32 + Parrellization uint32 + SaltLength uint32 + KeyLength uint32 +} diff --git a/hash/hasher_test.go b/hash/hasher_test.go index 14e88a6a1483..cde1e3f00756 100644 --- a/hash/hasher_test.go +++ b/hash/hasher_test.go @@ -231,4 +231,15 @@ func TestCompare(t *testing.T) { assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$pbkdf2-sha256$i=100000,l=32$1jP+5Zxpxgtee/iPxGgOz0RfE9/KJuDElP1ley4VxXcc$QJxzfvdbHYBpydCbHoFg3GJEqMFULwskiuqiJctoYpI"))) assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$pbkdf2-sha256$i=100000,l=32$1jP+5Zxpxgtee/iPxGgOz0RfE9/KJuDElP1ley4VxXc$QJxzfvdbHYBpydCbHoFg3GJEqMFULwskiuqiJctoYpII"))) assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$pbkdf2-sha512$I=100000,l=32$bdHBpn7OWOivJMVJypy2UqR0UnaD5prQXRZevj/05YU$+wArTfv1a+bNGO1iZrmEdVjhA+lL11wF4/IxpgYfPwc"))) + + assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$scrypt$ln=16384,r=8,p=1$2npRo7P03Mt8keSoMbyD/tKFWyUzjiQf2svUaNDSrhA=$MiCzNcIplSMqSBrm4HckjYqYhaVPPjTARTzwB1cVNYE="))) + assert.Nil(t, hash.CompareScrypt(context.Background(), []byte("test"), []byte("$scrypt$ln=16384,r=8,p=1$2npRo7P03Mt8keSoMbyD/tKFWyUzjiQf2svUaNDSrhA=$MiCzNcIplSMqSBrm4HckjYqYhaVPPjTARTzwB1cVNYE="))) + assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$scrypt$ln=16384,r=8,p=1$2npRo7P03Mt8keSoMbyD/tKFWyUzjiQf2svUaNDSrhA=$MiCzNcIplSMqSBrm4HckjYqYhaVPPjTARTzwB1cVNYF="))) + + assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$scrypt$2npRo7P03Mt8keSoMbyD/tKFWyUzjiQf2svUaNDSrhA=$MiCzNcIplSMqSBrm4HckjYqYhaVPPjTARTzwB1cVNYE="))) + assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$scrypt$ln=16384,r=8,p=1$(2npRo7P03Mt8keSoMbyD/tKFWyUzjiQf2svUaNDSrhA=$MiCzNcIplSMqSBrm4HckjYqYhaVPPjTARTzwB1cVNYE="))) + assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$scrypt$ln=16384,r=8,p=1$(2npRo7P03Mt8keSoMbyD/tKFWyUzjiQf2svUaNDSrhA=$(MiCzNcIplSMqSBrm4HckjYqYhaVPPjTARTzwB1cVNYE="))) + assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$scrypt$ln=16385,r=8,p=1$2npRo7P03Mt8keSoMbyD/tKFWyUzjiQf2svUaNDSrhA=$MiCzNcIplSMqSBrm4HckjYqYhaVPPjTARTzwB1cVNYE="))) + assert.Error(t, hash.Compare(context.Background(), []byte("tesu"), []byte("$scrypt$ln=16384,r=8,p=1$2npRo7P03Mt8keSoMbyD/tKFWyUzjiQf2svUaNDSrhA=$MiCzNcIplSMqSBrm4HckjYqYhaVPPjTARTzwB1cVNYE="))) + assert.Error(t, hash.Compare(context.Background(), []byte("tesu"), []byte("$scrypt$ln=abc,r=8,p=1$2npRo7P03Mt8keSoMbyD/tKFWyUzjiQf2svUaNDSrhA=$MiCzNcIplSMqSBrm4HckjYqYhaVPPjTARTzwB1cVNYE="))) } diff --git a/identity/.snapshots/TestHandler-case=should_be_able_to_import_users-with_scrypt_password.json b/identity/.snapshots/TestHandler-case=should_be_able_to_import_users-with_scrypt_password.json new file mode 100644 index 000000000000..20f96677c816 --- /dev/null +++ b/identity/.snapshots/TestHandler-case=should_be_able_to_import_users-with_scrypt_password.json @@ -0,0 +1,20 @@ +{ + "credentials": { + "password": { + "type": "password", + "identifiers": [ + "import-7@ory.sh" + ], + "config": { + }, + "version": 0 + } + }, + "schema_id": "default", + "state": "active", + "traits": { + "email": "import-7@ory.sh" + }, + "metadata_public": null, + "metadata_admin": null +} diff --git a/identity/handler_import.go b/identity/handler_import.go index 5e27cc287d50..40d3c3849553 100644 --- a/identity/handler_import.go +++ b/identity/handler_import.go @@ -45,7 +45,7 @@ func (h *Handler) importPasswordCredentials(ctx context.Context, i *Identity, cr creds.Config.HashedPassword = string(hashed) } - if !(hash.IsArgon2idHash(hashed) || hash.IsArgon2iHash(hashed) || hash.IsBcryptHash(hashed) || hash.IsPbkdf2Hash(hashed)) { + if !(hash.IsArgon2idHash(hashed) || hash.IsArgon2iHash(hashed) || hash.IsBcryptHash(hashed) || hash.IsPbkdf2Hash(hashed) || hash.IsScryptHash(hashed)) { return errors.WithStack(herodot.ErrBadRequest.WithReasonf("The imported password does not match any known hash format. For more information see https://www.ory.sh/dr/2")) } diff --git a/identity/handler_test.go b/identity/handler_test.go index f3bce9668f33..c3ea255284e7 100644 --- a/identity/handler_test.go +++ b/identity/handler_test.go @@ -266,6 +266,18 @@ func TestHandler(t *testing.T) { require.NoError(t, hash.Compare(ctx, []byte("123456"), []byte(gjson.GetBytes(actual.Credentials[identity.CredentialsTypePassword].Config, "hashed_password").String()))) }) + + t.Run("with scrypt password", func(t *testing.T) { + res := send(t, adminTS, "POST", "/identities", http.StatusCreated, identity.AdminCreateIdentityBody{Traits: []byte(`{"email": "import-7@ory.sh"}`), + Credentials: &identity.AdminIdentityImportCredentials{Password: &identity.AdminIdentityImportCredentialsPassword{ + Config: identity.AdminIdentityImportCredentialsPasswordConfig{HashedPassword: "$scrypt$ln=16384,r=8,p=1$ZtQva9xCHzlSELH/mA7Kj5KjH2tCrkbwYzdxknkL0QQ=$pnTcXKaWVT+FwFDdk3vO1K0J7ZgOxdSU1tCJNYmn8zI="}}}}) + actual, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, uuid.FromStringOrNil(res.Get("id").String())) + require.NoError(t, err) + + snapshotx.SnapshotTExceptMatchingKeys(t, identity.WithCredentialsAndAdminMetadataInJSON(*actual), append(ignoreDefault, "hashed_password")) + + require.NoError(t, hash.Compare(ctx, []byte("123456"), []byte(gjson.GetBytes(actual.Credentials[identity.CredentialsTypePassword].Config, "hashed_password").String()))) + }) }) t.Run("case=unable to set ID itself", func(t *testing.T) {