Skip to content

Commit da84a90

Browse files
feat(crypto): Add sha2 (based on crypt(3)) hashing algorithms (#59)
Adds crypt(3) style hashing and verifying for SHA-256 and SHA-512. Based on the information from passlib and https://www.akkadia.org/drepper/SHA-crypt.txt
1 parent d0607a4 commit da84a90

File tree

6 files changed

+805
-28
lines changed

6 files changed

+805
-28
lines changed

README.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,22 +29,24 @@ needs to be updated.
2929
### Algorithms
3030

3131
| Algorithm | Identifiers | Secure |
32-
|-----------------|--------------------------------------------------------------------| ------------------ |
32+
| --------------- | ------------------------------------------------------------------ | ------------------ |
3333
| [argon2][1] | argon2i, argon2id | :heavy_check_mark: |
3434
| [bcrypt][2] | 2, 2a, 2b, 2y | :heavy_check_mark: |
3535
| [md5-crypt][3] | 1 | :x: |
3636
| [md5 plain][4] | Hex encoded string | :x: |
3737
| [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: |
38+
| [sha2-crypt][6] | 5, 6 | :heavy_check_mark: |
39+
| [scrypt][7] | scrypt, 7 | :heavy_check_mark: |
40+
| [pbkpdf2][8] | pbkdf2, pbkdf2-sha224, pbkdf2-sha256, pbkdf2-sha384, pbkdf2-sha512 | :heavy_check_mark: |
4041

4142
[1]: https://pkg.go.dev/github.com/zitadel/passwap/argon2
4243
[2]: https://pkg.go.dev/github.com/zitadel/passwap/bcrypt
4344
[3]: https://pkg.go.dev/github.com/zitadel/passwap/md5
4445
[4]: https://pkg.go.dev/github.com/zitadel/passwap/md5plain
4546
[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
47+
[6]: https://pkg.go.dev/github.com/zitadel/passwap/sha2
48+
[7]: https://pkg.go.dev/github.com/zitadel/passwap/scrypt
49+
[8]: https://pkg.go.dev/github.com/zitadel/passwap/pbkdf2
4850

4951
### Encoding
5052

@@ -139,6 +141,21 @@ $md5salted-suffix$kJ4QkJaQ$3EbD/pJddrq5HW3mpZ4KZ1
139141

140142
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.
141143

144+
### SHA2 crypt
145+
146+
SHA2 Crypt shares its encoding scheme with MD5 Crypt, but uses SHA-256 or SHA-512 instead of MD5 and uses a different [hashing algorithm](https://www.akkadia.org/drepper/SHA-crypt.txt)
147+
The resulting Modular Crypt Format string looks as follows:
148+
149+
```
150+
$5$rounds=5000$RPvilwjD1ebXJfzg$5B8vYYiY.CVt1RlTTf8KbXBH3hsxY/GNooZaBBGWEc5
151+
(1) (2) (3) (4)
152+
```
153+
154+
1. The identifier is always `5` (SHA-256) or `6` (SHA-512)
155+
2. The cost parameter in rounds, which is a linear value - `5000` in this example. Note that according to the specification this part is optional (in which case the default of 5000 rounds will be used). In this implementation the rounds are always returned, even when they match the default
156+
3. Base64-like-encoded salt.
157+
4. Base64-like-encoded SHA-256/512 hash output of the password and salt combined.
158+
142159
### Scrypt
143160

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

internal/encoding/crypt3.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package encoding
2+
3+
const crypt3Encoding = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
4+
5+
// crypt(3) uses a slightly different Base64 scheme. Also called hash64 in PassLib
6+
func EncodeCrypt3(raw []byte) []byte {
7+
dest := make([]byte, 0, (len(raw)*8+6-1)/6)
8+
9+
v := uint(0)
10+
bits := uint(0)
11+
12+
for _, b := range raw {
13+
v |= (uint(b) << bits)
14+
15+
for bits = bits + 8; bits > 6; bits -= 6 {
16+
dest = append(dest, crypt3Encoding[v&63])
17+
v >>= 6
18+
}
19+
}
20+
dest = append(dest, crypt3Encoding[v&63])
21+
return dest
22+
}

internal/encoding/crypt3_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package encoding
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestEncodeCrypt3(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
raw []byte
12+
want []byte
13+
}{
14+
{
15+
name: "Empty input",
16+
raw: []byte{},
17+
want: []byte{'.'},
18+
},
19+
{
20+
name: "Single byte",
21+
raw: []byte{255},
22+
want: []byte{'z', '1'},
23+
},
24+
{
25+
name: "Two bytes",
26+
raw: []byte{255, 255},
27+
want: []byte{'z', 'z', 'D'},
28+
},
29+
{
30+
name: "Three bytes",
31+
raw: []byte{255, 255, 255},
32+
want: []byte{'z', 'z', 'z', 'z'},
33+
},
34+
{
35+
name: "Patterned input",
36+
raw: []byte{0, 1, 2, 3, 4, 5, 6, 7},
37+
want: []byte{'.', '2', 'U', '.', '1', 'E', 'E', '/', '4', 'Q', '.'},
38+
},
39+
}
40+
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
got := EncodeCrypt3(tt.raw)
44+
if !reflect.DeepEqual(got, tt.want) {
45+
t.Errorf("EncodeCrypt3() = %v, want %v", got, tt.want)
46+
}
47+
})
48+
}
49+
}

md5/md5.go

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"io"
1919
"strings"
2020

21+
"github.com/zitadel/passwap/internal/encoding"
2122
"github.com/zitadel/passwap/internal/salt"
2223
"github.com/zitadel/passwap/verifier"
2324
)
@@ -29,29 +30,8 @@ const (
2930
// Format of the Modular Crypt Format, as used by passlib.
3031
// See https://passlib.readthedocs.io/en/stable/lib/passlib.hash.md5_crypt.html#format
3132
Format = Prefix + "%s$%s"
32-
33-
// Encoding is the character set used for encoding salt and checksum.
34-
Encoding = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
3533
)
3634

37-
func encode(raw []byte) []byte {
38-
dest := make([]byte, 0, (len(raw)*8+6-1)/6)
39-
40-
v := uint(0)
41-
bits := uint(0)
42-
43-
for _, b := range raw {
44-
v |= (uint(b) << bits)
45-
46-
for bits = bits + 8; bits > 6; bits -= 6 {
47-
dest = append(dest, Encoding[v&63])
48-
v >>= 6
49-
}
50-
}
51-
dest = append(dest, Encoding[v&63])
52-
return dest
53-
}
54-
5535
var swaps = [md5.Size]int{12, 6, 0, 13, 7, 1, 14, 8, 2, 15, 9, 3, 5, 10, 4, 11}
5636

5737
// checksum implements https://passlib.readthedocs.io/en/stable/lib/passlib.hash.md5_crypt.html#algorithm
@@ -113,7 +93,7 @@ func checksum(password, salt []byte) []byte {
11393
swapped[i] = hash[j]
11494
}
11595

116-
return encode(swapped)
96+
return encoding.EncodeCrypt3(swapped)
11797
}
11898

11999
// 6 saltbytes result in 8 characters of encoded salt.
@@ -125,7 +105,7 @@ func hash(r io.Reader, password string) (string, error) {
125105
return "", fmt.Errorf("md5: %w", err)
126106
}
127107

128-
encSalt := encode(salt)
108+
encSalt := encoding.EncodeCrypt3(salt)
129109

130110
checksum := checksum([]byte(password), encSalt)
131111
return fmt.Sprintf(Format, encSalt, checksum), nil

0 commit comments

Comments
 (0)