From 2b44d55c3a712ccd29a5d31c0c7090df89e6af48 Mon Sep 17 00:00:00 2001 From: James Elliott Date: Fri, 25 Sep 2026 09:39:02 +1000 Subject: [PATCH 1/2] fix(bcrypt): preserve the version identifier and guard 2x digests Decoded digests always encoded with the 2b version identifier, so a 2a, 2x or 2y digest changed when it was encoded again. For the SHA256 variant the t parameter was also discarded and replaced with 2b. The 2x identifier marks digests produced by the crypt_blowfish sign extension bug. These were verified as if they were 2b digests, which is only correct for passwords made up of ASCII bytes. Passwords containing bytes with the high bit set silently failed to match, for example the crypt_blowfish reference vector for "\xa3". Decoded digests now keep their version identifier, while hashed digests continue to use 2b. Matching a standard 2x digest against a password containing non-ASCII bytes now returns ErrPasswordInvalid instead of reporting a mismatch. ASCII passwords, and the SHA256 variant which always passes base64 encoded input to bcrypt, are unaffected. --- algorithm/bcrypt/decoder.go | 11 +++- algorithm/bcrypt/digest.go | 24 +++++++- algorithm/bcrypt/regression_test.go | 89 +++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 3 deletions(-) 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..843e4ea 100644 --- a/algorithm/bcrypt/regression_test.go +++ b/algorithm/bcrypt/regression_test.go @@ -6,6 +6,8 @@ import ( 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 +117,90 @@ 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) { + // The SHA256 variant always passes base64 encoded input to bcrypt, so the 2x version is safe for any password. + digest, err := Decode("$bcrypt-sha256$v=2,t=2x,r=10$oYmTNJVOBi3hdhUYy4JqOe$jCuMDm.Pw9hhoF/FDC6sOi48yBAoWvC") + require.NoError(t, err) + + _, err = digest.MatchAdvanced("\xa3") + assert.NoError(t, err) +} From 54e12a3bfa8ab87fd9cbeb684097b8ba9cf4f379 Mon Sep 17 00:00:00 2001 From: James Elliott Date: Fri, 25 Sep 2026 09:49:53 +1000 Subject: [PATCH 2/2] refactor: suggestions --- algorithm/bcrypt/regression_test.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/algorithm/bcrypt/regression_test.go b/algorithm/bcrypt/regression_test.go index 843e4ea..e345b84 100644 --- a/algorithm/bcrypt/regression_test.go +++ b/algorithm/bcrypt/regression_test.go @@ -1,6 +1,7 @@ package bcrypt import ( + "strings" "testing" xbcrypt "github.com/go-crypt/x/bcrypt" @@ -197,10 +198,23 @@ func TestVersion2yMatchesNonASCIIPasswords(t *testing.T) { } func TestSHA256VariantVersion2xMatches(t *testing.T) { - // The SHA256 variant always passes base64 encoded input to bcrypt, so the 2x version is safe for any password. - digest, err := Decode("$bcrypt-sha256$v=2,t=2x,r=10$oYmTNJVOBi3hdhUYy4JqOe$jCuMDm.Pw9hhoF/FDC6sOi48yBAoWvC") + hasher, err := NewSHA256(WithIterations(IterationsMin)) require.NoError(t, err) - _, err = digest.MatchAdvanced("\xa3") + 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) }