Skip to content

Commit

Permalink
feat: allow importing (salted) SHA hashing algorithms (#2741)
Browse files Browse the repository at this point in the history
See #2422
  • Loading branch information
zambien authored Jan 28, 2023
1 parent acf9261 commit 132255e
Show file tree
Hide file tree
Showing 23 changed files with 540 additions and 180 deletions.
15 changes: 6 additions & 9 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ replace (

// Use the internal httpclient which can be generated in this codebase but mark it as the
// official SDK, allowing for the Ory CLI to consume Ory Kratos' CLI commands.
go.mongodb.org/mongo-driver => go.mongodb.org/mongo-driver v1.4.6
golang.org/x/sys => golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8
gopkg.in/DataDog/dd-trace-go.v1 => gopkg.in/DataDog/dd-trace-go.v1 v1.27.1-0.20201005154917-54b73b3e126a
)

Expand Down Expand Up @@ -99,9 +97,9 @@ require (
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.4
go.opentelemetry.io/otel v1.11.1
go.opentelemetry.io/otel/trace v1.11.1
golang.org/x/crypto v0.1.0
golang.org/x/net v0.3.0
golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783
golang.org/x/crypto v0.5.0
golang.org/x/net v0.5.0
golang.org/x/oauth2 v0.4.0
golang.org/x/sync v0.1.0
golang.org/x/tools v0.4.0
)
Expand Down Expand Up @@ -170,7 +168,6 @@ require (
github.com/go-playground/locales v0.13.0 // indirect
github.com/go-playground/universal-translator v0.17.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/gobuffalo/envy v1.10.2 // indirect
github.com/gobuffalo/flect v0.3.0 // indirect
github.com/gobuffalo/github_flavored_markdown v1.1.3 // indirect
Expand Down Expand Up @@ -319,9 +316,9 @@ require (
go.uber.org/zap v1.17.0 // indirect
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
golang.org/x/mod v0.7.0 // indirect
golang.org/x/sys v0.3.0 // indirect
golang.org/x/term v0.3.0 // indirect
golang.org/x/text v0.5.0 // indirect
golang.org/x/sys v0.4.0 // indirect
golang.org/x/term v0.4.0 // indirect
golang.org/x/text v0.6.0 // indirect
golang.org/x/time v0.1.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect
Expand Down
169 changes: 150 additions & 19 deletions go.sum

Large diffs are not rendered by default.

193 changes: 166 additions & 27 deletions hash/hash_comparator.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/md5" // #nosec G501
"crypto/md5" // #nosec G501
"crypto/sha1" // #nosec G505 - compatibility for imported passwords
"crypto/sha256"
"crypto/sha512"
"crypto/subtle"
"encoding/base64"
"fmt"
Expand Down Expand Up @@ -37,6 +40,10 @@ func Compare(ctx context.Context, password []byte, hash []byte) error {
return ComparePbkdf2(ctx, password, hash)
case IsScryptHash(hash):
return CompareScrypt(ctx, password, hash)
case IsSSHAHash(hash):
return CompareSSHA(ctx, password, hash)
case IsSHAHash(hash):
return CompareSHA(ctx, password, hash)
case IsFirebaseScryptHash(hash):
return CompareFirebaseScrypt(ctx, password, hash)
case IsMD5Hash(hash):
Expand Down Expand Up @@ -142,6 +149,31 @@ func CompareScrypt(_ context.Context, password []byte, hash []byte) error {
return errors.WithStack(ErrMismatchedHashAndPassword)
}

func CompareSSHA(_ context.Context, password []byte, hash []byte) error {
hasher, salt, hash, err := decodeSSHAHash(string(hash))

if err != nil {
return err
}

raw := append(password[:], salt[:]...)

return compareSHAHelper(hasher, raw, hash)
}

func CompareSHA(_ context.Context, password []byte, hash []byte) error {

hasher, pf, salt, hash, err := decodeSHAHash(string(hash))
if err != nil {
return err
}

r := strings.NewReplacer("{SALT}", string(salt), "{PASSWORD}", string(password))
raw := []byte(r.Replace(string(pf)))

return compareSHAHelper(hasher, raw, hash)
}

func CompareFirebaseScrypt(_ context.Context, password []byte, hash []byte) error {
// Extract the parameters, salt and derived key from the encoded password
// hash.
Expand Down Expand Up @@ -206,36 +238,36 @@ var (
isArgon2iHash = regexp.MustCompile(`^\$argon2i\$`)
isPbkdf2Hash = regexp.MustCompile(`^\$pbkdf2-sha[0-9]{1,3}\$`)
isScryptHash = regexp.MustCompile(`^\$scrypt\$`)
isSSHAHash = regexp.MustCompile(`^{SSHA(256|512)?}.*`)
isSHAHash = regexp.MustCompile(`^\$sha(1|256|512)\$`)
isFirebaseScryptHash = regexp.MustCompile(`^\$firescrypt\$`)
isMD5Hash = regexp.MustCompile(`^\$md5\$`)
)

func IsBcryptHash(hash []byte) bool {
return isBcryptHash.Match(hash)
}

func IsArgon2idHash(hash []byte) bool {
return isArgon2idHash.Match(hash)
}

func IsArgon2iHash(hash []byte) bool {
return isArgon2iHash.Match(hash)
}

func IsPbkdf2Hash(hash []byte) bool {
return isPbkdf2Hash.Match(hash)
}

func IsScryptHash(hash []byte) bool {
return isScryptHash.Match(hash)
}

func IsFirebaseScryptHash(hash []byte) bool {
return isFirebaseScryptHash.Match(hash)
}

func IsMD5Hash(hash []byte) bool {
return isMD5Hash.Match(hash)
func IsBcryptHash(hash []byte) bool { return isBcryptHash.Match(hash) }
func IsArgon2idHash(hash []byte) bool { return isArgon2idHash.Match(hash) }
func IsArgon2iHash(hash []byte) bool { return isArgon2iHash.Match(hash) }
func IsPbkdf2Hash(hash []byte) bool { return isPbkdf2Hash.Match(hash) }
func IsScryptHash(hash []byte) bool { return isScryptHash.Match(hash) }
func IsSSHAHash(hash []byte) bool { return isSSHAHash.Match(hash) }
func IsSHAHash(hash []byte) bool { return isSHAHash.Match(hash) }
func IsFirebaseScryptHash(hash []byte) bool { return isFirebaseScryptHash.Match(hash) }
func IsMD5Hash(hash []byte) bool { return isMD5Hash.Match(hash) }

func IsValidHashFormat(hash []byte) bool {
if IsBcryptHash(hash) ||
IsArgon2idHash(hash) ||
IsArgon2iHash(hash) ||
IsPbkdf2Hash(hash) ||
IsScryptHash(hash) ||
IsSSHAHash(hash) ||
IsSHAHash(hash) ||
IsFirebaseScryptHash(hash) ||
IsMD5Hash(hash) {
return true
} else {
return false
}
}

func decodeArgon2idHash(encodedHash string) (p *config.Argon2, salt, hash []byte, err error) {
Expand Down Expand Up @@ -339,6 +371,113 @@ func decodeScryptHash(encodedHash string) (p *Scrypt, salt, hash []byte, err err
return p, salt, hash, nil
}

// decodeSHAHash decodes SHA[1|256|512] encoded password hash in custom PHC format.
// format: $sha1$pf=<salting-format>$<salt>$<hash>
func decodeSHAHash(encodedHash string) (hasher string, pf, salt, hash []byte, err error) {
parts := strings.Split(encodedHash, "$")

if len(parts) != 5 {
return "", nil, nil, nil, ErrInvalidHash
}

hasher = parts[1]

_, err = fmt.Sscanf(parts[2], "pf=%s", &pf)
if err != nil {
return "", nil, nil, nil, err
}

pf, err = base64.StdEncoding.Strict().DecodeString(string(pf))
if err != nil {
return "", nil, nil, nil, err
}

salt, err = base64.StdEncoding.Strict().DecodeString(parts[3])
if err != nil {
return "", nil, nil, nil, err
}

hash, err = base64.StdEncoding.Strict().DecodeString(parts[4])
if err != nil {
return "", nil, nil, nil, err
}

return hasher, pf, salt, hash, nil
}

// used for CompareSHA and CompareSSHA
func compareSHAHelper(hasher string, raw []byte, hash []byte) error {

var sha []byte

switch hasher {
case "sha1":
sum := sha1.Sum(raw) // #nosec G401 - compatibility for imported passwords
sha = sum[:]
case "sha256":
sum := sha256.Sum256(raw)
sha = sum[:]
case "sha512":
sum := sha512.Sum512(raw)
sha = sum[:]
default:
return errors.WithStack(ErrMismatchedHashAndPassword)
}

encodedHash := []byte(base64.StdEncoding.EncodeToString(hash))
newEncodedHash := []byte(base64.StdEncoding.EncodeToString(sha))

// Check that the contents of the hashed passwords are identical.
// subtle.ConstantTimeCompare() is used to help prevent timing attacks.
if subtle.ConstantTimeCompare(encodedHash, newEncodedHash) == 1 {
return nil
}
return errors.WithStack(ErrMismatchedHashAndPassword)
}

// decodeSSHAHash decodes SSHA[1|256|512] encoded password hash in usual {SSHA...} format.
func decodeSSHAHash(encodedHash string) (hasher string, salt, hash []byte, err error) {
re := regexp.MustCompile(`\{([^}]*)\}`)
match := re.FindStringSubmatch(string(encodedHash))

var index_of_salt_begin int
var index_of_hash_begin int

switch match[1] {
case "SSHA":
hasher = "sha1"
index_of_hash_begin = 6
index_of_salt_begin = 20

case "SSHA256":
hasher = "sha256"
index_of_hash_begin = 9
index_of_salt_begin = 32

case "SSHA512":
hasher = "sha512"
index_of_hash_begin = 9
index_of_salt_begin = 64

default:
return "", nil, nil, ErrInvalidHash
}

decoded, err := base64.StdEncoding.DecodeString(string(encodedHash[index_of_hash_begin:]))
if err != nil {
return "", nil, nil, ErrInvalidHash
}

if len(decoded) < index_of_salt_begin+1 {
return "", nil, nil, ErrInvalidHash
}

salt = decoded[index_of_salt_begin:]
hash = decoded[:index_of_salt_begin]

return hasher, salt, hash, nil
}

// decodeFirebaseScryptHash decodes Firebase Scrypt encoded password hash.
// format: $firescrypt$ln=<mem_cost>,r=<rounds>,p=<parallelization>$<salt>$<hash>$<salt_separator>$<signer_key>
func decodeFirebaseScryptHash(encodedHash string) (p *Scrypt, salt, saltSeparator, hash, signerKey []byte, err error) {
Expand Down
50 changes: 50 additions & 0 deletions hash/hasher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,56 @@ func TestCompare(t *testing.T) {
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=")))

assert.Nil(t, hash.Compare(context.Background(), []byte("test123"), []byte("{SSHA}JFZFs0oHzxbMwkSJmYVeI8MnTDy/276a")))
assert.Nil(t, hash.CompareSSHA(context.Background(), []byte("test123"), []byte("{SSHA}JFZFs0oHzxbMwkSJmYVeI8MnTDy/276a")))
assert.Error(t, hash.CompareSSHA(context.Background(), []byte("badtest"), []byte("{SSHA}JFZFs0oHzxbMwkSJmYVeI8MnTDy/276a")))
assert.Error(t, hash.Compare(context.Background(), []byte(""), []byte("{SSHA}tooshort")))

assert.Nil(t, hash.Compare(context.Background(), []byte("test123"), []byte("{SSHA256}czO44OTV17PcF1cRxWrLZLy9xHd7CWyVYplr1rOhuMlx/7IK")))
assert.Nil(t, hash.CompareSSHA(context.Background(), []byte("test123"), []byte("{SSHA256}czO44OTV17PcF1cRxWrLZLy9xHd7CWyVYplr1rOhuMlx/7IK")))
assert.Error(t, hash.CompareSSHA(context.Background(), []byte("badtest"), []byte("{SSHA256}czO44OTV17PcF1cRxWrLZLy9xHd7CWyVYplr1rOhuMlx/7IK")))

assert.Nil(t, hash.Compare(context.Background(), []byte("test123"), []byte("{SSHA512}xPUl/px+1cG55rUH4rzcwxdOIPSB2TingLpiJJumN2xyDWN4Ix1WQG3ihnvHaWUE8MYNkvMi5rf0C9NYixHsE6Yh59M=")))
assert.Nil(t, hash.CompareSSHA(context.Background(), []byte("test123"), []byte("{SSHA512}xPUl/px+1cG55rUH4rzcwxdOIPSB2TingLpiJJumN2xyDWN4Ix1WQG3ihnvHaWUE8MYNkvMi5rf0C9NYixHsE6Yh59M=")))
assert.Error(t, hash.CompareSSHA(context.Background(), []byte("badtest"), []byte("{SSHA512}xPUl/px+1cG55rUH4rzcwxdOIPSB2TingLpiJJumN2xyDWN4Ix1WQG3ihnvHaWUE8MYNkvMi5rf0C9NYixHsE6Yh59M=")))
assert.Error(t, hash.CompareSSHA(context.Background(), []byte("test123"), []byte("{SSHAnotExistent}xPUl/px+1cG55rUH4rzcwxdOIPSB2TingLpiJJumN2xyDWN4Ix1WQG3ihnvHaWUE8MYNkvMi5rf0C9NYixHsE6Yh59M=")))

//pf: {SALT}{PASSWORD}
assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$sha1$pf=e1NBTFR9e1BBU1NXT1JEfQ==$NW9wbWtnejAzcg==$2qU2SGWP8viTM1md3FiI3+rjWXQ=")))
assert.Error(t, hash.Compare(context.Background(), []byte("wrongpass"), []byte("$sha1$pf=e1NBTFR9e1BBU1NXT1JEfQ==$NW9wbWtnejAzcg==$2qU2SGWP8viTM1md3FiI3+rjWXQ=")))
assert.Error(t, hash.Compare(context.Background(), []byte("tset"), []byte("$sha1$pf=e1NBTFR9e1BBU1NXT1JEfQ==$NW9wbWtnejAzcg==$2qU2SGWP8viTM1md3FiI3+rjWXQ=")))
// wrong salt
assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$sha1$pf=e1NBTFR9e1BBU1NXT1JEfQ==$cDJvb3ZrZGJ6cQ==$2qU2SGWP8viTM1md3FiI3+rjWXQ=")))
// salt not encoded
assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$sha1$pf=e1NBTFR9e1BBU1NXT1JEfQ==$5opmkgz03r$2qU2SGWP8viTM1md3FiI3+rjWXQ=")))
assert.Nil(t, hash.Compare(context.Background(), []byte("BwS^514g^cv@Z"), []byte("$sha1$pf=e1NBTFR9e1BBU1NXT1JEfQ==$NW9wbWtnejAzcg==$99h9net4BXl7qdTRaiGUobLROxM=")))
// no format string
assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$sha1$pf=$NW9wbWtnejAzcg==$2qU2SGWP8viTM1md3FiI3+rjWXQ=")))
assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$sha1$$NW9wbWtnejAzcg==$2qU2SGWP8viTM1md3FiI3+rjWXQ=")))
// wrong number of parameters
assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$sha1$NW9wbWtnejAzcg==$2qU2SGWP8viTM1md3FiI3+rjWXQ=")))
// pf: ??staticPrefix??{SALT}{PASSWORD}
assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$sha1$pf=Pz9zdGF0aWNQcmVmaXg/P3tTQUxUfXtQQVNTV09SRH0=$NW9wbWtnejAzcg==$SAAxMUn7jxckQXkBmsVF0nHwqso=")))
// pf: {PASSWORD}%%{SALT}
assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$sha1$pf=e1BBU1NXT1JEfSUle1NBTFR9$NW9wbWtnejAzcg==$YX0AW8/MW5ojUlnzTaR43ucHCog=")))
// pf: ${PASSWORD}${SALT}$
assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$sha1$pf=JHtQQVNTV09SRH0ke1NBTFR9JA==$NW9wbWtnejAzcg==$iE5n1yjX3oAdxRHwZ4u57I4LpQo=")))

//pf: {SALT}{PASSWORD}
assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$sha256$pf=e1NBTFR9e1BBU1NXT1JEfQ==$NW9wbWtnejAzcg==$0gfRVLCvtBCk20udLDEY5vNhujWx7RGjwRIS1ebMsLY=")))
assert.Nil(t, hash.CompareSHA(context.Background(), []byte("test"), []byte("$sha256$pf=e1NBTFR9e1BBU1NXT1JEfQ==$NW9wbWtnejAzcg==$0gfRVLCvtBCk20udLDEY5vNhujWx7RGjwRIS1ebMsLY=")))
assert.Error(t, hash.Compare(context.Background(), []byte("wrongpass"), []byte("$sha256$pf=e1NBTFR9e1BBU1NXT1JEfQ==$NW9wbWtnejAzcg==$0gfRVLCvtBCk20udLDEY5vNhujWx7RGjwRIS1ebMsLY=")))
//pf: {SALT}$${PASSWORD}
assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$sha256$pf=e1NBTFR9JCR7UEFTU1dPUkR9$NW9wbWtnejAzcg==$HokCOi9OtiZaZRvnkgemV3B4UUHpI7kA8zq/EZWH2NY=")))

//pf: {SALT}{PASSWORD}
assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$sha512$pf=e1NBTFR9e1BBU1NXT1JEfQ==$NW9wbWtnejAzcg==$6ctpVuApMNp0CgBXcdHw/GC562eFEFGr4gpgANX8ZYsX+j5B19IkdmOY2Fytsz3QUwSWdGcUjbqwgJGTH0UYvw==")))
assert.Nil(t, hash.CompareSHA(context.Background(), []byte("test"), []byte("$sha512$pf=e1NBTFR9e1BBU1NXT1JEfQ==$NW9wbWtnejAzcg==$6ctpVuApMNp0CgBXcdHw/GC562eFEFGr4gpgANX8ZYsX+j5B19IkdmOY2Fytsz3QUwSWdGcUjbqwgJGTH0UYvw==")))
assert.Error(t, hash.Compare(context.Background(), []byte("wrongpass"), []byte("$sha512$pf=e1NBTFR9e1BBU1NXT1JEfQ==$NW9wbWtnejAzcg==$6ctpVuApMNp0CgBXcdHw/GC562eFEFGr4gpgANX8ZYsX+j5B19IkdmOY2Fytsz3QUwSWdGcUjbqwgJGTH0UYvw==")))
//pf: {SALT}$${PASSWORD}
assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$sha512$pf=e1NBTFR9JCR7UEFTU1dPUkR9$NW9wbWtnejAzcg==$1F9BPW8UtdJkZ9Dhlf+D4X4dJ9xfuH8y04EfuCP2k4aGPPq/aWxU9/xe3LydHmYW1/K3zu3NFO9ETVrZettz3w==")))

assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$shaNotExistent$pf=e1NBTFR9e1BBU1NXT1JEfQ==$NW9wbWtnejAzcg==$6ctpVuApMNp0CgBXcdHw/GC562eFEFGr4gpgANX8ZYsX+j5B19IkdmOY2Fytsz3QUwSWdGcUjbqwgJGTH0UYvw==")))
assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$md5$CY9rzUYh03PK3k6DJie09g==")))
assert.Nil(t, hash.CompareMD5(context.Background(), []byte("test"), []byte("$md5$CY9rzUYh03PK3k6DJie09g==")))
assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$md5$WhBei51A4TKXgNYuoiZdig==")))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"password": {
"type": "password",
"identifiers": [
"import-5@ory.sh"
"import-hash-6@ory.sh"
],
"config": {
},
Expand All @@ -13,7 +13,7 @@
"schema_id": "default",
"state": "active",
"traits": {
"email": "import-5@ory.sh"
"email": "import-hash-6@ory.sh"
},
"metadata_public": null,
"metadata_admin": null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"password": {
"type": "password",
"identifiers": [
"import-6@ory.sh"
"import-hash-7@ory.sh"
],
"config": {
},
Expand All @@ -13,7 +13,7 @@
"schema_id": "default",
"state": "active",
"traits": {
"email": "import-6@ory.sh"
"email": "import-hash-7@ory.sh"
},
"metadata_public": null,
"metadata_admin": null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"password": {
"type": "password",
"identifiers": [
"import-4@ory.sh"
"import-hash-8@ory.sh"
],
"config": {
},
Expand All @@ -13,7 +13,7 @@
"schema_id": "default",
"state": "active",
"traits": {
"email": "import-4@ory.sh"
"email": "import-hash-8@ory.sh"
},
"metadata_public": null,
"metadata_admin": null
Expand Down
Loading

0 comments on commit 132255e

Please sign in to comment.