Skip to content

Commit d1b4e17

Browse files
authored
feat: support md5 hash import (#2725)
1 parent 14c79b4 commit d1b4e17

5 files changed

+128
-1
lines changed

hash/hash_comparator.go

+69
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"crypto/aes"
99
"crypto/cipher"
10+
"crypto/md5" // #nosec G501
1011
"crypto/subtle"
1112
"encoding/base64"
1213
"fmt"
@@ -38,6 +39,8 @@ func Compare(ctx context.Context, password []byte, hash []byte) error {
3839
return CompareScrypt(ctx, password, hash)
3940
case IsFirebaseScryptHash(hash):
4041
return CompareFirebaseScrypt(ctx, password, hash)
42+
case IsMD5Hash(hash):
43+
return CompareMD5(ctx, password, hash)
4144
default:
4245
return errors.WithStack(ErrUnknownHashAlgorithm)
4346
}
@@ -173,13 +176,38 @@ func CompareFirebaseScrypt(_ context.Context, password []byte, hash []byte) erro
173176
return errors.WithStack(ErrMismatchedHashAndPassword)
174177
}
175178

179+
func CompareMD5(_ context.Context, password []byte, hash []byte) error {
180+
// Extract the hash from the encoded password
181+
pf, salt, hash, err := decodeMD5Hash(string(hash))
182+
if err != nil {
183+
return err
184+
}
185+
186+
arg := password
187+
if salt != nil {
188+
r := strings.NewReplacer("{SALT}", string(salt), "{PASSWORD}", string(password))
189+
arg = []byte(r.Replace(string(pf)))
190+
}
191+
// #nosec G401
192+
otherHash := md5.Sum(arg)
193+
194+
// Check that the contents of the hashed passwords are identical. Note
195+
// that we are using the subtle.ConstantTimeCompare() function for this
196+
// to help prevent timing attacks.
197+
if subtle.ConstantTimeCompare(hash, otherHash[:]) == 1 {
198+
return nil
199+
}
200+
return errors.WithStack(ErrMismatchedHashAndPassword)
201+
}
202+
176203
var (
177204
isBcryptHash = regexp.MustCompile(`^\$2[abzy]?\$`)
178205
isArgon2idHash = regexp.MustCompile(`^\$argon2id\$`)
179206
isArgon2iHash = regexp.MustCompile(`^\$argon2i\$`)
180207
isPbkdf2Hash = regexp.MustCompile(`^\$pbkdf2-sha[0-9]{1,3}\$`)
181208
isScryptHash = regexp.MustCompile(`^\$scrypt\$`)
182209
isFirebaseScryptHash = regexp.MustCompile(`^\$firescrypt\$`)
210+
isMD5Hash = regexp.MustCompile(`^\$md5\$`)
183211
)
184212

185213
func IsBcryptHash(hash []byte) bool {
@@ -206,6 +234,10 @@ func IsFirebaseScryptHash(hash []byte) bool {
206234
return isFirebaseScryptHash.Match(hash)
207235
}
208236

237+
func IsMD5Hash(hash []byte) bool {
238+
return isMD5Hash.Match(hash)
239+
}
240+
209241
func decodeArgon2idHash(encodedHash string) (p *config.Argon2, salt, hash []byte, err error) {
210242
parts := strings.Split(encodedHash, "$")
211243
if len(parts) != 6 {
@@ -350,3 +382,40 @@ func decodeFirebaseScryptHash(encodedHash string) (p *Scrypt, salt, saltSeparato
350382

351383
return p, salt, saltSeparator, hash, signerKey, nil
352384
}
385+
386+
// decodeMD5Hash decodes MD5 encoded password hash.
387+
// format without salt: $md5$<hash>
388+
// format with salt $md5$pf=<salting-format>$<salt>$<hash>
389+
func decodeMD5Hash(encodedHash string) (pf, salt, hash []byte, err error) {
390+
parts := strings.Split(encodedHash, "$")
391+
392+
switch len(parts) {
393+
case 3:
394+
hash, err := base64.StdEncoding.Strict().DecodeString(parts[2])
395+
return nil, nil, hash, err
396+
case 5:
397+
_, err = fmt.Sscanf(parts[2], "pf=%s", &pf)
398+
if err != nil {
399+
return nil, nil, nil, err
400+
}
401+
402+
pf, err := base64.StdEncoding.Strict().DecodeString(string(pf))
403+
if err != nil {
404+
return nil, nil, nil, err
405+
}
406+
407+
salt, err = base64.StdEncoding.Strict().DecodeString(parts[3])
408+
if err != nil {
409+
return nil, nil, nil, err
410+
}
411+
412+
hash, err = base64.StdEncoding.Strict().DecodeString(parts[4])
413+
if err != nil {
414+
return nil, nil, nil, err
415+
}
416+
417+
return pf, salt, hash, nil
418+
default:
419+
return nil, nil, nil, ErrInvalidHash
420+
}
421+
}

hash/hasher_test.go

+26
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package hash_test
66
import (
77
"context"
88
"crypto/rand"
9+
"encoding/base64"
910
"fmt"
1011
"testing"
1112

@@ -249,4 +250,29 @@ func TestCompare(t *testing.T) {
249250
assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$scrypt$ln=16385,r=8,p=1$2npRo7P03Mt8keSoMbyD/tKFWyUzjiQf2svUaNDSrhA=$MiCzNcIplSMqSBrm4HckjYqYhaVPPjTARTzwB1cVNYE=")))
250251
assert.Error(t, hash.Compare(context.Background(), []byte("tesu"), []byte("$scrypt$ln=16384,r=8,p=1$2npRo7P03Mt8keSoMbyD/tKFWyUzjiQf2svUaNDSrhA=$MiCzNcIplSMqSBrm4HckjYqYhaVPPjTARTzwB1cVNYE=")))
251252
assert.Error(t, hash.Compare(context.Background(), []byte("tesu"), []byte("$scrypt$ln=abc,r=8,p=1$2npRo7P03Mt8keSoMbyD/tKFWyUzjiQf2svUaNDSrhA=$MiCzNcIplSMqSBrm4HckjYqYhaVPPjTARTzwB1cVNYE=")))
253+
254+
assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$md5$CY9rzUYh03PK3k6DJie09g==")))
255+
assert.Nil(t, hash.CompareMD5(context.Background(), []byte("test"), []byte("$md5$CY9rzUYh03PK3k6DJie09g==")))
256+
assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$md5$WhBei51A4TKXgNYuoiZdig==")))
257+
assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$md5$Dk/E5LQLsx4yt8QbUbvpdg==")))
258+
259+
assert.Nil(t, hash.Compare(context.Background(), []byte("ory"), []byte("$md5$ptoWyof5SobW+pbZu2QXoQ==")))
260+
assert.Nil(t, hash.CompareMD5(context.Background(), []byte("ory"), []byte("$md5$ptoWyof5SobW+pbZu2QXoQ==")))
261+
assert.Error(t, hash.Compare(context.Background(), []byte("ory"), []byte("$md5$4skj967KRHFsnPFoL5dMMw==")))
262+
263+
assert.ErrorIs(t, hash.Compare(context.Background(), []byte("ory"), []byte("$md5$$")), hash.ErrInvalidHash)
264+
assert.Error(t, hash.Compare(context.Background(), []byte("ory"), []byte("$md5$$$")))
265+
assert.Error(t, hash.Compare(context.Background(), []byte("ory"), []byte("$md5$pf=$$")))
266+
assert.ErrorIs(t, hash.Compare(context.Background(), []byte("ory"), []byte("$md5$pf=MTIz$Z$")), base64.CorruptInputError(0))
267+
assert.ErrorIs(t, hash.Compare(context.Background(), []byte("ory"), []byte("$md5$pf=MTIz$Z$")), base64.CorruptInputError(0))
268+
assert.ErrorIs(t, hash.Compare(context.Background(), []byte("ory"), []byte("$md5$pf=MTIz$MTIz$Z")), base64.CorruptInputError(0))
269+
270+
assert.Nil(t, hash.Compare(context.Background(), []byte("test"), []byte("$md5$pf=e1NBTFR9e1BBU1NXT1JEfQ==$MTIz$q+RdKCgc+ipCAcm5ChQwlQ=="))) // pf={SALT}{PASSWORD} salt=123
271+
assert.Error(t, hash.Compare(context.Background(), []byte("test"), []byte("$md5$pf=e1NBTFR9e1BBU1NXT1JEfQ==$MTIz$hh8ZTp1hGPPZQqcr4+UXSQ==")))
272+
273+
assert.Nil(t, hash.CompareMD5(context.Background(), []byte("test"), []byte("$md5$pf=e1NBTFR9JCR7UEFTU1dPUkR9$MTIzNA==$ud392Z8rfZ+Ou7ZFXYLKbA=="))) // pf={SALT}$${PASSWORD} salt=1234
274+
assert.Error(t, hash.CompareMD5(context.Background(), []byte("test1"), []byte("$md5$pf=e1NBTFR9JCR7UEFTU1dPUkR9$MTIzNA==$ud392Z8rfZ+Ou7ZFXYLKbA==")))
275+
276+
assert.Nil(t, hash.CompareMD5(context.Background(), []byte("ory"), []byte("$md5$pf=e1BBU1NXT1JEfXtTQUxUfSQ/$MTIzNDU2Nzg5$8PhwWanVRnpJAFK4NUjR0w=="))) // pf={PASSWORD}{SALT}$? salt=123456789
277+
assert.Error(t, hash.CompareMD5(context.Background(), []byte("ory1"), []byte("$md5$pf=e1BBU1NXT1JEfXtTQUxUfSQ/$MTIzNDU2Nzg5$8PhwWanVRnpJAFK4NUjR0w==")))
252278
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"credentials": {
3+
"password": {
4+
"type": "password",
5+
"identifiers": [
6+
7+
],
8+
"config": {
9+
},
10+
"version": 0
11+
}
12+
},
13+
"schema_id": "default",
14+
"state": "active",
15+
"traits": {
16+
"email": "[email protected]"
17+
},
18+
"metadata_public": null,
19+
"metadata_admin": null
20+
}

identity/handler_import.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func (h *Handler) importPasswordCredentials(ctx context.Context, i *Identity, cr
4848
creds.Config.HashedPassword = string(hashed)
4949
}
5050

51-
if !(hash.IsArgon2idHash(hashed) || hash.IsArgon2iHash(hashed) || hash.IsBcryptHash(hashed) || hash.IsPbkdf2Hash(hashed) || hash.IsScryptHash(hashed) || hash.IsFirebaseScryptHash(hashed)) {
51+
if !(hash.IsArgon2idHash(hashed) || hash.IsArgon2iHash(hashed) || hash.IsBcryptHash(hashed) || hash.IsPbkdf2Hash(hashed) || hash.IsScryptHash(hashed) || hash.IsFirebaseScryptHash(hashed) || hash.IsMD5Hash(hashed)) {
5252
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"))
5353
}
5454

identity/handler_test.go

+12
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,18 @@ func TestHandler(t *testing.T) {
282282

283283
require.NoError(t, hash.Compare(ctx, []byte("123456"), []byte(gjson.GetBytes(actual.Credentials[identity.CredentialsTypePassword].Config, "hashed_password").String())))
284284
})
285+
286+
t.Run("with md5 password", func(t *testing.T) {
287+
res := send(t, adminTS, "POST", "/identities", http.StatusCreated, identity.CreateIdentityBody{Traits: []byte(`{"email": "[email protected]"}`),
288+
Credentials: &identity.IdentityWithCredentials{Password: &identity.AdminIdentityImportCredentialsPassword{
289+
Config: identity.AdminIdentityImportCredentialsPasswordConfig{HashedPassword: "$md5$4QrcOUm6Wau+VuBX8g+IPg=="}}}})
290+
actual, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, uuid.FromStringOrNil(res.Get("id").String()))
291+
require.NoError(t, err)
292+
293+
snapshotx.SnapshotTExceptMatchingKeys(t, identity.WithCredentialsAndAdminMetadataInJSON(*actual), append(ignoreDefault, "hashed_password"))
294+
295+
require.NoError(t, hash.Compare(ctx, []byte("123456"), []byte(gjson.GetBytes(actual.Credentials[identity.CredentialsTypePassword].Config, "hashed_password").String())))
296+
})
285297
})
286298

287299
t.Run("case=unable to set ID itself", func(t *testing.T) {

0 commit comments

Comments
 (0)