Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement SHA-256 and SHA-512 hashed passwords #75

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions basic.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ var (
}{
{"", compareMD5HashAndPassword}, // default compareFunc
{"{SHA}", compareShaHashAndPassword},
{"$5$", compareShaCryptHashAndPassword},
{"$6$", compareShaCryptHashAndPassword},
// Bcrypt is complicated. According to crypt(3) from
// crypt_blowfish version 1.3 (fetched from
// http://www.openwall.com/crypt/crypt_blowfish-1.3.tar.gz), there
Expand Down Expand Up @@ -103,6 +105,20 @@ func compareShaHashAndPassword(hashedPassword, password []byte) error {
return nil
}

func compareShaCryptHashAndPassword(hashedPassword, password []byte) error {
hash, err := DissectShaCryptHash(hashedPassword)
if err != nil {
return errMismatchedHashAndPassword
}

result, err := SHACrypt(hash.Hash, password, hash.Salt, hash.Magic, hash.Rounds, hash.DefaultRounds)
if err != nil || subtle.ConstantTimeCompare(hashedPassword, result) != 1 {
return errMismatchedHashAndPassword
}

return nil
}

func compareMD5HashAndPassword(hashedPassword, password []byte) error {
parts := bytes.SplitN(hashedPassword, []byte("$"), 4)
if len(parts) != 4 {
Expand Down
11 changes: 11 additions & 0 deletions basic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ var basicSecrets = map[string]string{
"testsha": "{SHA}qvTGHdzF6KLavt4PO0gs2a6pQ00=",
"testmd5": "$apr1$0.KbAJur$4G9MiqUjDLCuihkMfmg6e1",
"testmd5broken": "$apr10.KbAJur$4G9MiqUjDLCuihkMfmg6e1",
"testsha256": "$5$Eg2QLTpmL3TegBv7$h8PsM/fa1xxOXmhUWWIQvV8.BVl9o3vax2S0C4C7Km3",
"testsha512": "$6$uqVy33l0y9YMJV15$UeR3rqmGvrgmc6cn6ZMKUrUqH9YBdrCbjTQK3K2gvprRWay45S6TC3fGQX4Ml4RY8cqkQ2f9CFqFmV02pyGhx.",
}

type credentials struct {
Expand Down Expand Up @@ -125,6 +127,10 @@ func TestBasicAuthWrap(t *testing.T) {
{"", "", http.StatusUnauthorized},
{"testsha", "invalid", http.StatusUnauthorized},
{"testsha", "hello", http.StatusOK},
{"testsha256", "invalid", http.StatusUnauthorized},
{"testsha256", "hello", http.StatusOK},
{"testsha512", "invalid", http.StatusUnauthorized},
{"testsha512", "hello", http.StatusOK},
} {
r, err := http.NewRequest("GET", ts.URL, nil)
if err != nil {
Expand All @@ -151,6 +157,10 @@ func TestCheckSecret(t *testing.T) {
{"openssl-md5", "$1$mvmz31IB$U9KpHBLegga2doA0e3s3N0"},
{"htpasswd-sha", "{SHA}vFznddje0Ht4+pmO0FaxwrUKN/M="},
{"htpasswd-bcrypt", "$2y$10$Q6GeMFPd0dAxhQULPDdAn.DFy6NDmLaU0A7e2XoJz7PFYAEADFKbC"},
{"openssl-sha256", "$5$qgB401R/ggz11Q5U$QAsQZuMF.xfkj7A0QrEvWpYgcStxtU8V3Wj5DSLOSI0"},
{"openssl-sha512", "$6$lseRR5fEdsK0sOkR$QTkArA5Z/arPmd78I7qmi8Wj/4bc8CbNw0FH59SYVXCfesr.AqOJINkGx/aaZ6gKYDbmYeFPSSMjMFW9HrMwR."},
{"0123456789012345678901234567890123456789", "$5$Kpm4hE9Eu9MU.vG8$G8z0lSskQaziCzlCbHSEDmoYr3kLhd7ineD3p0RiWD8"},
{"01234567890123456789012345678901234567890123456789012345678901234567890123456789", "$6$U1JDv/VIVgQ103od$4oHmr5qqJIJExEZfLzz0z3VfNznjcxTIL7c1RACsBWJnk/FPAc/oHwFGLZ0OJQaN.obx/2NdwYlGuiGu4KJ5K/"},
// common bcrypt test vectors
{"", "$2a$06$DCq7YPn5Rq63x1Lad4cll.TV4S6ytwfsfvkgY8jIucDrjc8deX1s."},
{"", "$2a$08$HqWuK6/Ng6sg9gQzbLrgb.Tl.ZHfXLhvt/SgVyWhQqgqcZ7ZuUtye"},
Expand Down Expand Up @@ -193,3 +203,4 @@ func TestCheckSecret(t *testing.T) {
})
}
}

248 changes: 248 additions & 0 deletions shacrypt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
package auth

import (
"bytes"
"crypto"
"errors"
"strconv"
)

type SHAHash struct {
Hash crypto.Hash
Magic []byte
Rounds uint
DefaultRounds bool
Salt []byte
Digest []byte
}

type SHACryptAlgo struct {
algo crypto.Hash
swaps []uint8
}

const (
shaEncoding = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
cryptPassDelim = "$"
cryptPassRounds = "rounds="
shaRoundsDefault = uint(5000)
shaRoundsMin = uint(1000)
shaRoundsMax = uint(999999999)
)

var (
shaCryptAlgo = map[string]*SHACryptAlgo{
"$5$": {crypto.SHA256, []uint8{
20, 10, 0, 11, 1, 21, 2, 22, 12, 23, 13, 3, 14, 4, 24, 5, 25, 15,
26, 16, 6, 17, 7, 27, 8, 28, 18, 29, 19, 9, 30, 31,
}},
"$6$": {crypto.SHA512, []uint8{
42, 21, 0, 1, 43, 22, 23, 2, 44, 45, 24, 3, 4, 46, 25, 26, 5, 47,
48, 27, 6, 7, 49, 28, 29, 8, 50, 51, 30, 9, 10, 52, 31, 32, 11, 53,
54, 33, 12, 13, 55, 34, 35, 14, 56, 57, 36, 15, 16, 58, 37, 38, 17, 59,
60, 39, 18, 19, 61, 40, 41, 20, 62, 63,
}},
}

cryptPassStructureError = errors.New("hashed password structure mismatch")
missingByteSwapMapperError = errors.New("unable to map SHA digest")
)

// SHACrypt implements SHA-crypt, as openssl does, following instructions in
// https://www.akkadia.org/drepper/SHA-crypt.txt
// It's 21 complex digest creating steps, so expect nothing easy to read.
func SHACrypt(hash crypto.Hash, password, salt, magic []byte, rounds uint, defaultRounds bool) ([]byte, error) {
// #1 - #3
A := hash.New()
A.Write(password)
A.Write(salt)

intermediate := make([]byte, 0, hash.Size())
// #4 - #8
B := hash.New()
B.Write(password)
B.Write(salt)
B.Write(password)
intermediate = B.Sum(intermediate[:0])

// #9
i := len(password)
for ; i > hash.Size(); i -= hash.Size() {
A.Write(intermediate)
}
// #10
A.Write(intermediate[:i])

// #11
for i = len(password); i > 0; i >>= 1 {
// last bit is set to 1
if i&1 != 0 {
A.Write(intermediate)
} else {
A.Write(password)
}
}

// #12
ADigest := A.Sum(nil)

// #13 - #15
B.Reset()
DP := B
for i = 0; i < len(password); i++ {
DP.Write(password)
}
intermediate = DP.Sum(intermediate[:0])

// #16
i = len(password)
P := make([]byte, 0, i)
for ; i > hash.Size(); i -= hash.Size() {
P = append(P, intermediate...)
}
P = append(P, intermediate[:i]...)

// #17 - #19
B.Reset()
DS := B
times := 16 + uint8(ADigest[0])
for ; times > 0; times-- {
DS.Write(salt)
}
intermediate = DS.Sum(intermediate[:0])

// #20
i = len(salt)
S := make([]byte, 0, i)
for ; i > hash.Size(); i -= hash.Size() {
S = append(S, intermediate...)
}
S = append(S, intermediate[:i]...)

// #21
finalDigest := append(intermediate[:0], ADigest...)
for rCount := uint(0); rCount < rounds; rCount++ {
B.Reset()
R := B
var seq []byte
if rCount%2 != 0 {
seq = P
} else {
seq = finalDigest
}
R.Write(seq)
if rCount%3 != 0 {
R.Write(S)
}
if rCount%7 != 0 {
R.Write(P)
}
if rCount%2 != 0 {
R.Write(finalDigest)
} else {
R.Write(P)
}
finalDigest = R.Sum(finalDigest[:0])
}

mapping, err := getSwapBytes(string(magic))
if err != nil {
return nil, missingByteSwapMapperError
}

digestLength := encodedLength(hash)
result := make([]byte, 0, 37+digestLength)
// #22 a)
result = append(result, magic...)
// #22 b)
if !defaultRounds {
result = append(strconv.AppendUint(append(result, []byte("rounds")...), uint64(rounds), 10), cryptPassDelim...)
}
// #22 c) + d)
result = append(append(result, salt[:16]...), cryptPassDelim...)
// #22 e) result
// base64 encode following sha-crypt rules [#22 e)]
v := uint(0)
bits := uint(0)
for _, idx := range mapping {
v |= uint(finalDigest[idx]) << bits
for bits = bits + 8; bits > 6; bits -= 6 {
result = append(result, shaEncoding[v&0x3f])
v >>= 6
}
}
result = append(result, shaEncoding[v&0x3f])

return result, nil
}

// DissectShaCryptHash splits SHA-256/512 password hash into it's parts.
// optional 'rounds=N$' is signaled
func DissectShaCryptHash(hashedPassword []byte) (*SHAHash, error) {
rounds := shaRoundsDefault
defaultRounds := true
parts := bytes.Split(hashedPassword, []byte(cryptPassDelim))
offset := 0

if len(parts) < 4 {
return nil, cryptPassStructureError
}

if len(parts) > 4 {
if len(parts) != 5 || !bytes.HasPrefix(parts[2], []byte(cryptPassRounds)) {
return nil, cryptPassStructureError
}

offset += 1
defaultRounds = false
i, e := strconv.ParseUint(string(bytes.TrimPrefix(parts[2], []byte(cryptPassRounds))), 10, 32)

if e != nil {
return nil, cryptPassStructureError
}

// 'i' is uint64 but parsed to fit into 32 bit and 'rounds' as uint is at least 32 bit
rounds = uint(i)
if rounds < shaRoundsMin {
rounds = shaRoundsMin
}
if rounds > shaRoundsMax {
rounds = shaRoundsMax
}
}

magic := make([]byte, 0, 2*len(cryptPassDelim) + len(parts[1]))
magic = append(append(append(magic, cryptPassDelim...), parts[1]...), cryptPassDelim...)

if hash, err := getHash(string(magic)); err != nil {
return nil, cryptPassStructureError
} else {
salt := parts[2+offset]
digest := parts[3+offset]

if len(digest) != encodedLength(hash) {
return nil, cryptPassStructureError
}

result := SHAHash{hash, magic, rounds, defaultRounds, salt, digest}
return &result, nil
}
}

func encodedLength(h crypto.Hash) int {
return ((h.Size() * 8) + 5) / 6
}

func getHash(magic string) (crypto.Hash, error) {
if a, ok := shaCryptAlgo[magic]; ok {
return a.algo, nil
}
return 0, errors.New("unable to gather hash algorithm")
}

func getSwapBytes(magic string) ([]uint8, error) {
if a, ok := shaCryptAlgo[magic]; ok {
return a.swaps, nil
}
return nil, errors.New("unable to gather hash specific bytes swapping")
}
38 changes: 38 additions & 0 deletions shacrypt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package auth

import (
"fmt"
"testing"
)

func TestDissectSha(t *testing.T) {
t.Parallel()
type testData struct {
secret string
result bool
}
data := []testData{
{"$5$qgB401R/ggz11Q5U$QAsQZuMF.xfkj7A0QrEvWpYgcStxtU8V3Wj5DSLOSI0", true},
{"$5$rounds=5000$qgB401R/ggz11Q5U$QAsQZuMF.xfkj7A0QrEvWpYgcStxtU8V3Wj5DSLOSI0", true},
{"$5$rounds=5000$foobar$qgB401R/ggz11Q5U$QAsQZuMF.xfkj7A0QrEvWpYgcStxtU8V3Wj5DSLOSI0", false},
{"$5$QAsQZuMF.xfkj7A0QrEvWpYgcStxtU8V3Wj5DSLOSI0", false},
{"$6$lseRR5fEdsK0sOkR$QTkArA5Z/arPmd78I7qmi8Wj/4bc8CbNw0FH59SYVXCfesr.AqOJINkGx/aaZ6gKYDbmYeFPSSMjMFW9HrMwR.", true},
{"$6$rounds=5000$lseRR5fEdsK0sOkR$QTkArA5Z/arPmd78I7qmi8Wj/4bc8CbNw0FH59SYVXCfesr.AqOJINkGx/aaZ6gKYDbmYeFPSSMjMFW9HrMwR.", true},
{"$6$rounds=5000$foobar$lseRR5fEdsK0sOkR$QTkArA5Z/arPmd78I7qmi8Wj/4bc8CbNw0FH59SYVXCfesr.AqOJINkGx/aaZ6gKYDbmYeFPSSMjMFW9HrMwR.", false},
{"$6$QTkArA5Z/arPmd78I7qmi8Wj/4bc8CbNw0FH59SYVXCfesr.AqOJINkGx/aaZ6gKYDbmYeFPSSMjMFW9HrMwR.", false},
}
for i, tc := range data {
t.Run(fmt.Sprintf("Vector%d", i), func(t *testing.T) {
t.Parallel()
_, err := DissectShaCryptHash([]byte(tc.secret))
if !tc.result && err == nil {
t.Error("DissectShaCrypthHash returned no error, want one")
return
}
if tc.result && err != nil {
t.Errorf("DissectShaCrypthHash returned error: %v, want none", err)
return
}
})
}
}