diff --git a/algorithm/bcrypt/decoder.go b/algorithm/bcrypt/decoder.go index 3dc5446..bc3ff1a 100644 --- a/algorithm/bcrypt/decoder.go +++ b/algorithm/bcrypt/decoder.go @@ -102,10 +102,13 @@ func decoderParts(encodedDigest string) (variant Variant, parts []string, err er return variant, nil, fmt.Errorf("%w: identifier '%s' is not an encoded %s digest", algorithm.ErrEncodedHashInvalidIdentifier, parts[1], AlgName) } - return variant, parts[2:], nil + return variant, parts[1:], nil } func decode(variant Variant, parts []string) (digest algorithm.Digest, err error) { + identifier := parts[0] + parts = parts[1:] + countParts := len(parts) var ( @@ -122,6 +125,8 @@ func decode(variant Variant, parts []string) (digest algorithm.Digest, err error return nil, algorithm.ErrEncodedHashInvalidFormat } + decoded.version = identifier + if decoded.iterations, err = strconv.Atoi(parts[0]); err != nil { return nil, fmt.Errorf("%w: iterations could not be parsed: %v", algorithm.ErrEncodedHashInvalidOptionValue, err) } @@ -173,8 +178,10 @@ func decode(variant Variant, parts []string) (digest algorithm.Digest, err error for _, param := range params { switch param.Key { - case oV, oT: + case oV: break + case oT: + decoded.version = param.Value case oR: decoded.iterations, err = param.Int() default: diff --git a/algorithm/bcrypt/digest.go b/algorithm/bcrypt/digest.go index 7010a7e..6172171 100644 --- a/algorithm/bcrypt/digest.go +++ b/algorithm/bcrypt/digest.go @@ -13,6 +13,8 @@ import ( type Digest struct { variant Variant + version string + iterations int salt, key []byte @@ -41,6 +43,10 @@ func (d *Digest) MatchBytesAdvanced(passwordBytes []byte) (match bool, err error return false, fmt.Errorf(algorithm.ErrFmtDigestMatch, AlgName, fmt.Errorf("%w: key has 0 bytes", algorithm.ErrPasswordInvalid)) } + if d.variant == VariantStandard && d.version == AlgIdentifierVerX && !isASCII(passwordBytes) { + return false, fmt.Errorf(algorithm.ErrFmtDigestMatch, AlgName, fmt.Errorf("%w: the %s version can't be verified for passwords containing non-ASCII bytes", algorithm.ErrPasswordInvalid, AlgIdentifierVerX)) + } + input := d.variant.EncodeInput(passwordBytes, d.salt) var key []byte @@ -54,7 +60,13 @@ func (d *Digest) MatchBytesAdvanced(passwordBytes []byte) (match bool, err error // Encode returns the encoded form of this bcrypt.Digest. func (d *Digest) Encode() string { - return d.variant.Encode(d.iterations, AlgIdentifier, bcrypt.Base64Encode(d.salt), d.key) + version := d.version + + if version == "" { + version = AlgIdentifier + } + + return d.variant.Encode(d.iterations, version, bcrypt.Base64Encode(d.salt), d.key) } // String returns the storable format of the bcrypt.Digest encoded hash. @@ -86,3 +98,13 @@ func (d *Digest) defaults() { d.iterations = IterationsDefault } } + +func isASCII(b []byte) bool { + for _, c := range b { + if c >= 0x80 { + return false + } + } + + return true +} diff --git a/algorithm/bcrypt/regression_test.go b/algorithm/bcrypt/regression_test.go index 3e9f45f..e345b84 100644 --- a/algorithm/bcrypt/regression_test.go +++ b/algorithm/bcrypt/regression_test.go @@ -1,11 +1,14 @@ package bcrypt import ( + "strings" "testing" xbcrypt "github.com/go-crypt/x/bcrypt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/go-crypt/crypt/algorithm" ) func TestDecodeRejectsUnusableCost(t *testing.T) { @@ -115,3 +118,103 @@ const ( validSHA256Salt = "3XCpXfcQBjcbXFHTLcbFju" validSHA256Key = "AXNZ1B7NPTf7XyCqUKcvIUOB5eKKZ4C" ) + +func TestDecodePreservesVersionIdentifier(t *testing.T) { + testCases := []string{ + "$2a$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW", + "$2b$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW", + "$2x$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW", + "$2y$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW", + "$bcrypt-sha256$v=2,t=2a,r=10$oYmTNJVOBi3hdhUYy4JqOe$jCuMDm.Pw9hhoF/FDC6sOi48yBAoWvC", + "$bcrypt-sha256$v=2,t=2b,r=10$oYmTNJVOBi3hdhUYy4JqOe$jCuMDm.Pw9hhoF/FDC6sOi48yBAoWvC", + "$bcrypt-sha256$v=2,t=2x,r=10$oYmTNJVOBi3hdhUYy4JqOe$jCuMDm.Pw9hhoF/FDC6sOi48yBAoWvC", + "$bcrypt-sha256$v=2,t=2y,r=10$oYmTNJVOBi3hdhUYy4JqOe$jCuMDm.Pw9hhoF/FDC6sOi48yBAoWvC", + } + + for _, encoded := range testCases { + t.Run(encoded, func(t *testing.T) { + digest, err := Decode(encoded) + require.NoError(t, err) + + assert.Equal(t, encoded, digest.Encode()) + }) + } +} + +func TestHashedDigestsUseVersion2b(t *testing.T) { + for _, variant := range []Variant{VariantStandard, VariantSHA256} { + t.Run(variant.String(), func(t *testing.T) { + hasher, err := New(WithVariant(variant), WithIterations(IterationsMin)) + require.NoError(t, err) + + digest, err := hasher.Hash("password") + require.NoError(t, err) + + switch variant { + case VariantSHA256: + assert.Contains(t, digest.Encode(), "t=2b,") + default: + assert.Regexp(t, `^\$2b\$`, digest.Encode()) + } + }) + } +} + +func TestVersion2xMatchesASCIIPasswords(t *testing.T) { + // The 2x version only differs from the other versions for passwords containing bytes with the high bit set. + digest, err := Decode("$2x$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW") + require.NoError(t, err) + + match, err := digest.MatchAdvanced("U*U") + assert.NoError(t, err) + assert.True(t, match) + + match, err = digest.MatchAdvanced("U*V") + assert.NoError(t, err) + assert.False(t, match) +} + +func TestVersion2xRejectsNonASCIIPasswords(t *testing.T) { + // Reference vector from crypt_blowfish, which produced 2x digests using its sign extension bug. + digest, err := Decode("$2x$05$/OK.fbVrR/bpIqNJ5ianF.CE5elHaaO4EbggVDjb8P19RukzXSM3e") + require.NoError(t, err) + + match, err := digest.MatchAdvanced("\xa3") + assert.False(t, match) + assert.ErrorIs(t, err, algorithm.ErrPasswordInvalid) + assert.EqualError(t, err, "bcrypt match error: password is invalid: the 2x version can't be verified for passwords containing non-ASCII bytes") + + assert.False(t, digest.Match("\xa3")) +} + +func TestVersion2yMatchesNonASCIIPasswords(t *testing.T) { + // Reference vector from crypt_blowfish. + digest, err := Decode("$2y$05$/OK.fbVrR/bpIqNJ5ianF.Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq") + require.NoError(t, err) + + match, err := digest.MatchAdvanced("\xa3") + assert.NoError(t, err) + assert.True(t, match) +} + +func TestSHA256VariantVersion2xMatches(t *testing.T) { + hasher, err := NewSHA256(WithIterations(IterationsMin)) + require.NoError(t, err) + + hashed, err := hasher.Hash("\xa3") + require.NoError(t, err) + + encoded := strings.Replace(hashed.Encode(), ",t=2b,", ",t=2x,", 1) + require.Contains(t, encoded, ",t=2x,") + + digest, err := Decode(encoded) + require.NoError(t, err) + + match, err := digest.MatchAdvanced("\xa3") + assert.NoError(t, err) + assert.True(t, match) + + match, err = digest.MatchAdvanced("\xa4") + assert.NoError(t, err) + assert.False(t, match) +}