diff --git a/basic.go b/basic.go index f328a32..9c1d26f 100644 --- a/basic.go +++ b/basic.go @@ -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 @@ -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 { diff --git a/basic_test.go b/basic_test.go index e45095b..6baee5a 100644 --- a/basic_test.go +++ b/basic_test.go @@ -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 { @@ -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 { @@ -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"}, @@ -193,3 +203,4 @@ func TestCheckSecret(t *testing.T) { }) } } + diff --git a/shacrypt.go b/shacrypt.go new file mode 100644 index 0000000..e1953c3 --- /dev/null +++ b/shacrypt.go @@ -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") +} diff --git a/shacrypt_test.go b/shacrypt_test.go new file mode 100644 index 0000000..522c3af --- /dev/null +++ b/shacrypt_test.go @@ -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 + } + }) + } +}