golang - bcrypt로 패스워드를 hash화 할 때 패스워드의 bytes 수에 조심하자

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 를 사용할 수 있다.

패스워드의 byte 수

적어도 golang.org/x/crypto/bcrypt에서 72바이트 이내로 제한하는 요구가 있다. 선두의 72 바이트가 동일하면 73 바이트 이후가 다른 패스워드가 동일한 것으로 판단해 버린다.

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 함수를 구현하였다.

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
}


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)
        }
    }
}

출처: https://qiita.com/yoheimuta/items/57c7119dcdb46edf35c7


이 글은 2019-07-15에 작성되었습니다.