Skip to content

Commit d0607a4

Browse files
mtriscmuhlemmer
andauthored
feat: verifier for MD5 salted passwords (#58)
* feat($rootScope): verifier for MD5 salted passwords Allow salted passwords hashed with MD5 to be verified. Accept salt as prefix or as suffix of the password. Don't allow to use as hasher. new feature * Update md5salted/md5salted.go Co-authored-by: Tim Möhlmann <muhlemmer@gmail.com> * Update md5salted/md5salted.go Co-authored-by: Tim Möhlmann <muhlemmer@gmail.com> * Update md5salted/md5salted_test.go Co-authored-by: Tim Möhlmann <muhlemmer@gmail.com> * Update md5salted/md5salted_test.go Co-authored-by: Tim Möhlmann <muhlemmer@gmail.com> * Update md5salted/md5salted_test.go Co-authored-by: Tim Möhlmann <muhlemmer@gmail.com> --------- Co-authored-by: Tim Möhlmann <muhlemmer@gmail.com>
1 parent 667b64e commit d0607a4

File tree

3 files changed

+313
-10
lines changed

3 files changed

+313
-10
lines changed

README.md

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,23 @@ needs to be updated.
2828

2929
### Algorithms
3030

31-
| Algorithm | Identifiers | Secure |
32-
| -------------- | ------------------------------------------------------------------ | ------------------ |
33-
| [argon2][1] | argon2i, argon2id | :heavy_check_mark: |
34-
| [bcrypt][2] | 2, 2a, 2b, 2y | :heavy_check_mark: |
35-
| [md5-crypt][3] | 1 | :x: |
36-
| [md5 plain][4] | Hex encoded string | :x: |
37-
| [scrypt][5] | scrypt, 7 | :heavy_check_mark: |
38-
| [pbkpdf2][6] | pbkdf2, pbkdf2-sha224, pbkdf2-sha256, pbkdf2-sha384, pbkdf2-sha512 | :heavy_check_mark: |
31+
| Algorithm | Identifiers | Secure |
32+
|-----------------|--------------------------------------------------------------------| ------------------ |
33+
| [argon2][1] | argon2i, argon2id | :heavy_check_mark: |
34+
| [bcrypt][2] | 2, 2a, 2b, 2y | :heavy_check_mark: |
35+
| [md5-crypt][3] | 1 | :x: |
36+
| [md5 plain][4] | Hex encoded string | :x: |
37+
| [md5 salted][5] | md5salted-suffix,md5salted-prefix | :x: |
38+
| [scrypt][6] | scrypt, 7 | :heavy_check_mark: |
39+
| [pbkpdf2][7] | pbkdf2, pbkdf2-sha224, pbkdf2-sha256, pbkdf2-sha384, pbkdf2-sha512 | :heavy_check_mark: |
3940

4041
[1]: https://pkg.go.dev/github.com/zitadel/passwap/argon2
4142
[2]: https://pkg.go.dev/github.com/zitadel/passwap/bcrypt
4243
[3]: https://pkg.go.dev/github.com/zitadel/passwap/md5
4344
[4]: https://pkg.go.dev/github.com/zitadel/passwap/md5plain
44-
[5]: https://pkg.go.dev/github.com/zitadel/passwap/scrypt
45-
[6]: https://pkg.go.dev/github.com/zitadel/passwap/pbkdf2
45+
[5]: https://pkg.go.dev/github.com/zitadel/passwap/md5salted
46+
[6]: https://pkg.go.dev/github.com/zitadel/passwap/scrypt
47+
[7]: https://pkg.go.dev/github.com/zitadel/passwap/pbkdf2
4648

4749
### Encoding
4850

@@ -121,6 +123,22 @@ For example passwap can verify passwords hashed by the following methods:
121123
MD5 is considered cryptographically broken and insecure. Also hashing without salt is a bad idea.
122124
Therefore passwap only supports verification to allow applications to migrate to better methods.
123125

126+
### MD5 Salted
127+
128+
MD5 Salted are base64 encode digest of password+salt (resp. salt+password)
129+
The resulting MD5salted Format string looks as follows:
130+
131+
```
132+
$md5salted-suffix$kJ4QkJaQ$3EbD/pJddrq5HW3mpZ4KZ1
133+
(1) (2) (3)
134+
```
135+
136+
1. The identifier is md5salted-suffix or md5salted-prefix
137+
2. Salt string (will be added to password in exactly this form).
138+
3. Base64-like-encoded MD5 hash output of the password and salt combined (password+salt or salt+password).
139+
140+
There is no cost parameter for MD5 because MD5 is old and is considered too light and insecure. It is provided to verify and migrate to a better algorithm. Do not use for new hashes.
141+
124142
### Scrypt
125143

126144
Scrypt uses standard raw Base64 encoding (no padding) for the salt and hash.

md5salted/md5salted.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Package md5salted provides hashing and verification of
2+
// md5 encoded passwords prefixed or suffixed with salt.
3+
//
4+
// Note that md5 is considered cryptographically broken
5+
// and should not be used for new applications.
6+
// This package is only provided for legacy applications
7+
// that wish to migrate away from md5 to newer hashing methods.
8+
package md5salted
9+
10+
import (
11+
"crypto/md5"
12+
"crypto/subtle"
13+
"encoding/base64"
14+
"fmt"
15+
"strings"
16+
17+
"github.com/zitadel/passwap/verifier"
18+
)
19+
20+
const (
21+
Identifier = "md5salted"
22+
IdentifierSuffixed = Identifier + "-suffix"
23+
IdentifierPrefixed = Identifier + "-prefix"
24+
Prefix = "$" + Identifier
25+
26+
Format = "$%s$%s$%s"
27+
)
28+
29+
var scanFormat = strings.ReplaceAll(Format, "$", " ")
30+
31+
type checker struct {
32+
salt string
33+
hash string
34+
saltpasswfunc func(string) []byte
35+
}
36+
37+
func (c *checker) setSaltPasswFunc(id string) {
38+
switch id {
39+
case IdentifierPrefixed:
40+
c.saltpasswfunc = func(passw string) []byte {
41+
return []byte(c.salt + passw)
42+
}
43+
case IdentifierSuffixed:
44+
c.saltpasswfunc = func(passw string) []byte {
45+
return []byte(passw + c.salt)
46+
}
47+
default:
48+
c.saltpasswfunc = nil
49+
}
50+
}
51+
52+
func parse(encoded string) (*checker, error) {
53+
if !strings.HasPrefix(encoded, Prefix) {
54+
return nil, nil
55+
}
56+
57+
// scanning needs a space separated string, instead of dollar signs.
58+
encoded = strings.ReplaceAll(encoded, "$", " ")
59+
var c checker
60+
var id string
61+
_, err := fmt.Sscanf(encoded, scanFormat, &id, &c.salt, &c.hash)
62+
if err != nil {
63+
return nil, fmt.Errorf("md5salted parse: %w", err)
64+
}
65+
c.setSaltPasswFunc(id)
66+
if c.saltpasswfunc == nil {
67+
return nil, fmt.Errorf("md5salted unknown identifier: %s", id)
68+
}
69+
return &c, nil
70+
}
71+
72+
func (c *checker) verify(password string) (verifier.Result, error) {
73+
checksum := md5.Sum(c.saltpasswfunc(password))
74+
decoded, err := base64.StdEncoding.DecodeString(c.hash)
75+
if err != nil {
76+
return verifier.Skip, err
77+
}
78+
79+
return verifier.Result(
80+
subtle.ConstantTimeCompare(checksum[:], decoded),
81+
), nil
82+
}
83+
84+
// Verify parses encoded and verifies password against the checksum.
85+
func Verify(encoded, password string) (verifier.Result, error) {
86+
c, err := parse(encoded)
87+
if err != nil || c == nil {
88+
return verifier.Skip, err
89+
}
90+
91+
return c.verify(password)
92+
}
93+
94+
var Verifier = verifier.VerifyFunc(Verify)

md5salted/md5salted_test.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package md5salted
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
7+
"github.com/zitadel/passwap/internal/testvalues"
8+
"github.com/zitadel/passwap/verifier"
9+
)
10+
11+
const (
12+
Password = "Test1000!"
13+
SaltEncoded = "c2FsdA=="
14+
MD5SaltedSHash = "R58+SD/95ORa9VZ9BPS5FA=="
15+
MD5SaltedPHash = "0M2MYNUmNumHqqQ+kmuTUQ=="
16+
MD5SaltedEncodedS = "$md5salted-suffix$c2FsdA==$R58+SD/95ORa9VZ9BPS5FA=="
17+
MD5SaltedEncodedP = "$md5salted-prefix$c2FsdA==$0M2MYNUmNumHqqQ+kmuTUQ=="
18+
)
19+
20+
func Test_parse(t *testing.T) {
21+
type args struct {
22+
encoded string
23+
}
24+
tests := []struct {
25+
name string
26+
args args
27+
want *checker
28+
wantErr bool
29+
}{
30+
{
31+
name: "not md5",
32+
args: args{testvalues.EncodedBcrypt2b},
33+
},
34+
{
35+
name: "not md5",
36+
args: args{testvalues.MD5Encoded},
37+
},
38+
{
39+
name: "scan error",
40+
args: args{"$md5salted$foo"},
41+
wantErr: true,
42+
},
43+
{
44+
name: "wrong identifier",
45+
args: args{"$md5salted-unknown$foo$foo"},
46+
wantErr: true,
47+
},
48+
{
49+
name: "success suffix",
50+
args: args{MD5SaltedEncodedS},
51+
want: &checker{
52+
hash: MD5SaltedSHash,
53+
salt: SaltEncoded,
54+
},
55+
},
56+
{
57+
name: "success prefix",
58+
args: args{MD5SaltedEncodedP},
59+
want: &checker{
60+
hash: MD5SaltedPHash,
61+
salt: SaltEncoded,
62+
},
63+
},
64+
}
65+
for _, tt := range tests {
66+
t.Run(tt.name, func(t *testing.T) {
67+
got, err := parse(tt.args.encoded)
68+
if !tt.wantErr && got == nil && err == nil {
69+
return
70+
}
71+
if (err != nil) != tt.wantErr {
72+
t.Errorf("parse() error = %v, wantErr %v", err, tt.wantErr)
73+
return
74+
}
75+
if got != nil && tt.want != nil {
76+
if got.salt != tt.want.salt || got.hash != tt.want.hash || got.saltpasswfunc == nil {
77+
t.Errorf("parse() = %v, want %v", got, tt.want)
78+
}
79+
}
80+
})
81+
}
82+
}
83+
84+
func Test_checker_verify(t *testing.T) {
85+
type args struct {
86+
password string
87+
}
88+
tests := []struct {
89+
name string
90+
args args
91+
want verifier.Result
92+
encoded string
93+
}{
94+
{
95+
name: "success suffix",
96+
args: args{Password},
97+
want: verifier.OK,
98+
encoded: MD5SaltedEncodedS,
99+
},
100+
{
101+
name: "success prefix",
102+
args: args{Password},
103+
want: verifier.OK,
104+
encoded: MD5SaltedEncodedP,
105+
},
106+
{
107+
name: "hash decode error",
108+
args: args{Password},
109+
want: verifier.Skip,
110+
encoded: "$md5salted-prefix$c2FsdA==$~~~~~~~",
111+
},
112+
{
113+
name: "wrong password suffix",
114+
args: args{"foobar"},
115+
want: verifier.Fail,
116+
encoded: MD5SaltedEncodedS,
117+
},
118+
{
119+
name: "wrong password prefix",
120+
args: args{"foobar"},
121+
want: verifier.Fail,
122+
encoded: MD5SaltedEncodedP,
123+
},
124+
}
125+
for _, tt := range tests {
126+
t.Run(tt.name, func(t *testing.T) {
127+
parsed, _ := parse(tt.encoded)
128+
got, _ := parsed.verify(tt.args.password)
129+
if got != tt.want {
130+
t.Errorf("checker.verify() = %v, want %v", got, tt.want)
131+
}
132+
})
133+
}
134+
}
135+
136+
func TestVerify(t *testing.T) {
137+
type args struct {
138+
encoded string
139+
password string
140+
}
141+
tests := []struct {
142+
name string
143+
args args
144+
want verifier.Result
145+
wantErr bool
146+
}{
147+
{
148+
name: "decode error",
149+
args: args{"$md5salted$salt$foo", Password},
150+
want: verifier.Skip,
151+
wantErr: true,
152+
},
153+
{
154+
name: "wrong prefix",
155+
args: args{testvalues.ScryptEncoded, Password},
156+
want: verifier.Skip,
157+
},
158+
{
159+
name: "wrong password suffix",
160+
args: args{MD5SaltedEncodedS, "foobar"},
161+
want: verifier.Fail,
162+
},
163+
{
164+
name: "success suffix",
165+
args: args{MD5SaltedEncodedS, Password},
166+
want: verifier.OK,
167+
},
168+
{
169+
name: "wrong password prefix",
170+
args: args{MD5SaltedEncodedP, "foobar"},
171+
want: verifier.Fail,
172+
},
173+
{
174+
name: "success prefix",
175+
args: args{MD5SaltedEncodedP, Password},
176+
want: verifier.OK,
177+
},
178+
}
179+
for _, tt := range tests {
180+
t.Run(tt.name, func(t *testing.T) {
181+
got, err := Verify(tt.args.encoded, tt.args.password)
182+
if (err != nil) != tt.wantErr {
183+
t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr)
184+
return
185+
}
186+
if !reflect.DeepEqual(got, tt.want) {
187+
t.Errorf("Verify() = %v, want %v", got, tt.want)
188+
}
189+
})
190+
}
191+
}

0 commit comments

Comments
 (0)