From 906c04bdc1bcc49cb36977fc165b629a756d197a Mon Sep 17 00:00:00 2001 From: Mathias Tausig Date: Wed, 5 Oct 2022 22:24:19 +0200 Subject: [PATCH 1/2] Initialize module --- go/go.mod | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 go/go.mod diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..4d63105 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,2 @@ +module github.com/epixoip/hmac-bcrypt/go +go 1.13 From 35bad2e2799024fdf5151e86dd993e1153b3c7e4 Mon Sep 17 00:00:00 2001 From: Mathias Tausig Date: Fri, 7 Oct 2022 00:23:09 +0200 Subject: [PATCH 2/2] Implement hmac-bcrypt in go --- go/.gitignore | 1 + go/go.mod | 3 ++ go/hmacBcrypt.go | 70 ++++++++++++++++++++++++++++++++++++++++++ go/hmacBcrypt_test.go | 71 +++++++++++++++++++++++++++++++++++++++++++ go/settings.go | 68 +++++++++++++++++++++++++++++++++++++++++ go/settings_test.go | 20 ++++++++++++ 6 files changed, 233 insertions(+) create mode 100644 go/.gitignore create mode 100644 go/hmacBcrypt.go create mode 100644 go/hmacBcrypt_test.go create mode 100644 go/settings.go create mode 100644 go/settings_test.go diff --git a/go/.gitignore b/go/.gitignore new file mode 100644 index 0000000..c14a297 --- /dev/null +++ b/go/.gitignore @@ -0,0 +1 @@ +go.sum \ No newline at end of file diff --git a/go/go.mod b/go/go.mod index 4d63105..4fc2647 100644 --- a/go/go.mod +++ b/go/go.mod @@ -1,2 +1,5 @@ module github.com/epixoip/hmac-bcrypt/go + go 1.13 + +require github.com/go-crypt/x v0.1.2 diff --git a/go/hmacBcrypt.go b/go/hmacBcrypt.go new file mode 100644 index 0000000..db2352c --- /dev/null +++ b/go/hmacBcrypt.go @@ -0,0 +1,70 @@ +// Package hmacbcrypt implemts the `hmac-bcrypt` password hashing function, a secure scheme for using the bcrypt primitive +package hmacbcrypt + +import ( + "crypto/hmac" + "crypto/sha512" + "encoding/base64" + "fmt" + "strings" + + "github.com/go-crypt/x/bcrypt" +) + +const defaultPepper = "hmac_bcrypt" + +// HmacBcryptHash creates a hash using the `hmac-bcrypt` password hashing function +// settings is a string which can be empty for default settings or configure certain aspects of the algorithm. The following values are allowed: +// "$2a" ... equivalent to the default settings +// "$2a$" ... The integer value cost specifies the cost factor used for bcrypt. This defaults to 13 +// "$2a$$" ... If the salt to be used for hashing is specified manually, it has to be exectly 22 bcrypt-base64 encoded characers. Otherwise, a random salt will be generated +// If pepper is left empty, a default value "hmac_bcrypt" will be used +// +// Returns the hash string in the format "$2a$$" +func HmacBcryptHash(password, settings, pepper string) (string, error) { + parsedSettings, err := parseSettings(settings) + if err != nil { + return "", fmt.Errorf("Could not parse settings: %v", err) + } + if pepper == "" { + pepper = defaultPepper + } + + preHashMac := hmac.New(sha512.New, []byte(pepper)) + preHashMac.Write([]byte(password)) + preHash := base64.StdEncoding.EncodeToString(preHashMac.Sum(nil)) + + midHash, err := bcrypt.GenerateFromPasswordSalt([]byte(preHash), parsedSettings.Salt, parsedSettings.Cost) + if err != nil { + return "", fmt.Errorf("could not calculate bcrypt hash: %v", err) + } + + postHashMac := hmac.New(sha512.New, []byte(pepper)) + postHashMac.Write(midHash) + postHash := base64.StdEncoding.EncodeToString(postHashMac.Sum(nil)) + //Trailing padding characters from the hash must be removed + postHash = strings.TrimSuffix(postHash, "==") + + return fmt.Sprintf("%v%v", parsedSettings.Str(), postHash), nil +} + +// HmacBcryptVerify checcks, if a hash has been generated from the specified password +// password is the password to be verified against the hash +// expected is a hash stiring as created by the HmacBcryptHash function +// If pepper is left empty, a default value "hmac_bcrypt" will be used +func HmacBcryptVerify(password, expected, pepper string) bool { + if len(expected) < 59 { //31 (checksum) + 22 (salt) + 1 ($) + 1 (cost) + 1 ($) + 2 (2a) + 1 ($) = 59 min length + return false + } + lastDollarPos := strings.LastIndex(expected, "$") + if lastDollarPos == -1 || len(expected) < lastDollarPos+23 { + return false + } + + settingsStr := expected[:lastDollarPos+23] + recalculated, err := HmacBcryptHash(password, settingsStr, pepper) + if err != nil { + return false + } + return recalculated == expected +} diff --git a/go/hmacBcrypt_test.go b/go/hmacBcrypt_test.go new file mode 100644 index 0000000..e282cf4 --- /dev/null +++ b/go/hmacBcrypt_test.go @@ -0,0 +1,71 @@ +package hmacbcrypt + +import ( + "regexp" + "testing" +) + +func Test_hmac_bcrypt_hash(t *testing.T) { + type args struct { + password string + settings string + pepper string + } + tests := []struct { + name string + args args + wantRegex string + wantErr bool + }{ + { + name: "Supply password only", wantRegex: "^\\$2a\\$[0-9]{2}\\$[.\\/+A-Za-z0-9]{108}$", wantErr: false, + args: args{password: "test-pass"}, + }, + { + name: "Supply password and cost", wantRegex: "^\\$2a\\$10\\$[.\\/+A-Za-z0-9]{108}$", wantErr: false, + args: args{password: "test-pass", settings: "$2a$10$"}, + }, + { + name: "Supply password and cost + salt", wantRegex: "^\\$2a\\$10\\$v\\.vnO5oVlX/5zJM9TTXSz\\.[.\\/+A-Za-z0-9]{86}$", wantErr: false, + args: args{password: "test-pass", settings: "$2a$10$v.vnO5oVlX/5zJM9TTXSz."}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := HmacBcryptHash(tt.args.password, tt.args.settings, tt.args.pepper) + if (err != nil) != tt.wantErr { + t.Errorf("hmac_bcrypt_hash() error = %v, wantErr %v", err, tt.wantErr) + return + } + match, err := regexp.Match(tt.wantRegex, []byte(got)) + if err != nil || !match { + t.Errorf("hmac_bcrypt_hash() '%v' not in the expected format '%v'.", got, tt.wantRegex) + } + }) + } +} + +func Test_hmac_bcrypt_verify(t *testing.T) { + type args struct { + password string + expected string + pepper string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "Supply password and cost + salt + pepper", want: true, + args: args{password: "test-pass", pepper: "test-pepper", expected: "$2a$13$v.vnO5oVlX/5zJM9TTXSz.JMdh9WwErhl6x9XMOEBs5x1R1FxuPC29TMJSMeAEnUlkEgbZw6r0FFZ9jFN07eykXAMgNZH3WrZSqxQkj4qKEQ"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := HmacBcryptVerify(tt.args.password, tt.args.expected, tt.args.pepper); got != tt.want { + t.Errorf("hmac_bcrypt_verify() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/go/settings.go b/go/settings.go new file mode 100644 index 0000000..3675fb2 --- /dev/null +++ b/go/settings.go @@ -0,0 +1,68 @@ +package hmacbcrypt + +import ( + "fmt" + "strconv" + "strings" + + "github.com/go-crypt/x/bcrypt" +) + +const defaultHashIdentifier = "2a" +const defaultCost = 13 + +type settings struct { + Cost int + Salt []byte +} + +func (s settings) Str() string { + return fmt.Sprintf("$%v$%v$%v", defaultHashIdentifier, s.Cost, string(bcrypt.Base64Encode(s.Salt))) +} + +func parseSettings(settingsString string) (settings, error) { + if settingsString == "" { + return parseSettings("$2a") + } + settingsParts := strings.Split(settingsString, "$") + if len(settingsParts) == 1 || settingsParts[0] != "" { + // no $ found in settings string + return settings{}, fmt.Errorf("settings string must start with a $ character") + } + if settingsParts[1] != defaultHashIdentifier { + return settings{}, fmt.Errorf("unexpected hash identifier %v (expected: %v)", settingsParts[1], defaultHashIdentifier) + } + cost := defaultCost + var salt []byte + var err error + if len(settingsParts) >= 3 { + costString := settingsParts[2] + cost, err = strconv.Atoi(costString) + if err != nil { + return settings{}, fmt.Errorf("could not parse cost value '%v': %v", costString, err) + } + } + if len(settingsParts) == 4 && settingsParts[3] != "" { + saltB64 := settingsParts[3] + salt, err = bcrypt.Base64Decode([]byte(saltB64)) + if err != nil { + return settings{}, fmt.Errorf("could not decode salt value '%v': %v", saltB64, err) + } + } + if len(settingsParts) > 4 { + return settings{}, fmt.Errorf("settings strings contains too many parts") + } + ret := settings{ + Cost: cost, + Salt: salt, + } + if salt == nil { + salt, err := bcrypt.NewSalt() + if err != nil { + return settings{}, fmt.Errorf("could not generate salt value: %v", err) + } + ret.Salt = salt + } + + return ret, nil +} diff --git a/go/settings_test.go b/go/settings_test.go new file mode 100644 index 0000000..2233684 --- /dev/null +++ b/go/settings_test.go @@ -0,0 +1,20 @@ +package hmacbcrypt + +import "testing" + +func Test_ParseSettings_illegalSettingsString(t *testing.T) { + tests := []string{ + "2a", "$3b", "$2a$xx$", "$2a$10$####", "$2a$10$v.vnO5oVlX/5zJM9TTXSz.$foo$", + } + + for _, tt := range tests { + name := "Illegal settings string " + tt + t.Run(name, func(t *testing.T) { + _, err := parseSettings(tt) + if err == nil { + t.Errorf("Settings string '%v' was expected to be illegal", tt) + return + } + }) + } +}