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 Go version of hmac-bcrypt #2

Open
wants to merge 2 commits into
base: main
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
1 change: 1 addition & 0 deletions go/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
go.sum
5 changes: 5 additions & 0 deletions go/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module github.com/epixoip/hmac-bcrypt/go

go 1.13

require github.com/go-crypt/x v0.1.2
70 changes: 70 additions & 0 deletions go/hmacBcrypt.go
Original file line number Diff line number Diff line change
@@ -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$<cost>" ... The integer value cost specifies the cost factor used for bcrypt. This defaults to 13
// "$2a$<cost>$<salt>" ... 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$<cost>$<salt(len=22)><hash(len=86)>"
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
}
71 changes: 71 additions & 0 deletions go/hmacBcrypt_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
68 changes: 68 additions & 0 deletions go/settings.go
Original file line number Diff line number Diff line change
@@ -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
}
20 changes: 20 additions & 0 deletions go/settings_test.go
Original file line number Diff line number Diff line change
@@ -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
}
})
}
}