bcrypt でパスワードをハッシュ化する場合はパスワードの bytes 数に気をつける

# bcrypt

bcrypt はよく使われているパスワードのハッシュ関数です。

個人的に印象的だったのは、Slack のユーザー DB がアクセス可能になってしまった障害のときにパスワードが一方向ハッシュ化されていることが excuse に使われていました。そのハッシュ関数が bcrypt です。

- Slack’s hashing function is bcrypt with a randomly generated salt per-password which makes it computationally infeasible that your password could be recreated from the hashed form.
 - https://slackhq.com/march-2015-security-incident-and-the-launch-of-two-factor-authentication-3cdcc8efba29 より

bcrypt が生成するハッシュは `$2a$10$OTKlbteacFY8DOeZY5imi.wvNLmJ1WDenLlDSzXfFxizVX.D1BNfu` 形式の 60 文字です。

Go では golang.org/x/crypto/bcrypt が使えます。

# パスワードの bytes 数

少なくとも golang.org/x/crypto/bcrypt では 72 bytes 以内に制限する必要があります。
先頭の 72 bytes が同じで 73 bytes 目以降が違うパスワードが同じと判断されてしまうためです。

- when using bcrypt you should be aware that it limits your maximum password length to 50-72 bytes. The exact length depends on the bcrypt implementation you are using
 - https://dzone.com/articles/be-aware-that-bcrypt-has-a-maximum-password-length より

以下のコード例で境界を確認しています。

# コード

ハッシュを生成する Generate 関数と、そのハッシュとハッシュ化前の値が一致しているかを確認する Compare 関数を実装します。

```go
package password

import (
	"testing"

	"golang.org/x/crypto/bcrypt"
	"regexp"
	"strings"
)

// Generate は平文のパスワードから一方向ハッシュを生成する
func Generate(password string) (string, error) {
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return "", err
	}
	return string(hash), nil
}

// Compare は一方向ハッシュと平文のパスワードを比較して、一致しているかどうかとエラーを返す
func Compare(hash, password string) (bool, error) {
	err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
	if err != nil {
		if err == bcrypt.ErrMismatchedHashAndPassword {
			// MEMO: err を wrap して詳細を伝えると良い
			return false, err
		}
		return false, err
	}
	return true, nil
}
```

```go
func TestGenerate(t *testing.T) {
	tests := []struct {
		name          string
		inputPassword string
	}{
		{
			name:          "ハッシュを生成できる",
			inputPassword: strings.Repeat("w", 30),
		},
	}

	for _, test := range tests {
		hash, err := Generate(test.inputPassword)
		if err != nil {
			t.Errorf("[%s] got err %v, but want nil", test.name, err)
			continue
		}
		if !regexp.MustCompile(`\$2a\$10\$.{53}`).Match([]byte(hash)) {
			t.Errorf("got %s, but want passwordhash should be a bcrypto format", hash)
		}
	}
}

func TestCompare(t *testing.T) {
	tests := []struct {
		name          string
		inputHash     string
		inputPassword string
		wantMatched   bool
	}{
		{
			name:          "一致することを確認する",
			inputHash:     "$2a$10$jRtzjPGiLZdUMErjW0M8oeG/JQHbMHBIqIjAFI73X28cD0wqwAT56",
			inputPassword: "a5020327-3086-4cbd-ae9d-bd480a2a08c8",
			wantMatched:   true,
		},
		{
			name:          "一致しないことを確認する",
			inputHash:     "$2a$10$iwzHZAQ8erjNMDzqhlA3i.YY2Tp.ge0wDxZDznU5J3jZaaDXR32YK",
			inputPassword: "a5020327-3086-4cbd-ae9d-bd480a2a08c8",
		},
	}

	for _, test := range tests {
		matched, err := Compare(test.inputHash, test.inputPassword)
		if matched != test.wantMatched {
			t.Errorf("[%s] got %v but want %v (error %v)", test.name, matched, test.wantMatched, err)
		}
	}
}

func TestGenerateAndCompare(t *testing.T) {
	tests := []struct {
		name                  string
		inputGeneratePassword string
		inputComparePassword  string
		wantMatched           bool
	}{
		{
			name: "生成したハッシュと一致しないことを確認する",
			inputGeneratePassword: strings.Repeat("w", 50),
			inputComparePassword:  strings.Repeat("k", 50),
		},
		{
			name: "生成したハッシュと一致することを確認する",
			inputGeneratePassword: strings.Repeat("w", 50),
			inputComparePassword:  strings.Repeat("w", 50),
			wantMatched:           true,
		},
		{
			name: "73 bytes 目以降が一致しない文字列同士が一致となってしまうことを確認する",
			inputGeneratePassword: strings.Repeat("w", 72) + "a",
			inputComparePassword:  strings.Repeat("w", 72) + "d",
			wantMatched:           true,
		},
		{
			name: "72 bytes 目が一致しない文字列同士は不一致になることを確認する",
			inputGeneratePassword: strings.Repeat("w", 71) + "a",
			inputComparePassword:  strings.Repeat("w", 71) + "d",
		},
	}

	for _, test := range tests {
		hash, err := Generate(test.inputGeneratePassword)
		if err != nil {
			t.Errorf("[%s] got err %v, but want nil", test.name, err)
			continue
		}
		matched, err := Compare(hash, test.inputComparePassword)
		if matched != test.wantMatched {
			t.Errorf("[%s] got %v but want %v (error %v)", test.name, matched, test.wantMatched, err)
		}
	}
}
```