diff --git a/README.md b/README.md index 7313cd3..f03ac9d 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,3 @@ $ ./test 2024/09/07 18:42:35 Password confirmed, saving new password. 2024/09/07 18:42:35 The new password hash to save is: $6$4Eu/l5e.otcRj0rJ$YAlwxJD9pZY9.Z2TjseCbkXiUIrFU2AXh9DPEm5Z1SagxP..xaQCsz7jAgfW4nmUbLh.o23pEZGvvxPCLltf11 ``` - -## Known issues - - - It is possible to generate password hashes that are incompatible with libxcrypt by setting a large round count. This may be mitigated in the future by adding an option to disable compatibility and otherwise require compatible parameters to be set. diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..acef36e --- /dev/null +++ b/agents.md @@ -0,0 +1,22 @@ +# go-passwd + +This project is a Go library for password hashing and verification, designed to be compatible with `libcrypt` standards. + +# Code Style Guidelines + +- Write pragmatic, systems-oriented Go focused on correctness and clarity. +- Favor explicit control flow and simple data structures over abstraction. +- Use short, consistent receiver names. +- Exported names are clear and descriptive; internal helpers are lowerCamelCase. +- Return errors when callers can act; otherwise log and continue safely. +- Log operational details at debug level, lifecycle events at info, and failures at error level. +- Avoid panics, hidden side effects, and over-engineering. +- Comments are functional and intent-focused, explaining why something exists or what role it plays. +- Exported types and methods have short, direct doc comments that describe responsibility. +- Comments are operational in tone, written for someone maintaining or debugging the system. +- Inline comments are brief and precise, explaining non-obvious logic or marking logical phases of a function. +- Functions with multiple logical steps use short section comments to label each phase (e.g. "Validate input.", "Persist to database.", "Build response."). +- No conversational language, jokes, or speculative notes—comments are concise and purposeful. +- The code is expected to remain readable without comments; comments add clarity where reasoning is not immediately obvious. +- The name of the element (func, struct, var, ect) being commented should be the first word in the comment. +- Comments are expected to be a complete sentence with ending notation such as a period. We are humans with proper english. diff --git a/bcrypt.go b/bcrypt.go index 2cd4793..de84167 100644 --- a/bcrypt.go +++ b/bcrypt.go @@ -88,10 +88,15 @@ func (a *BCrypt) Hash(password []byte, salt []byte) ([]byte, error) { cStr := a.Params[idx+1:] if c, cErr := strconv.ParseUint(cStr, 10, 32); cErr == nil { cost = uint32(c) + } else { + return nil, cErr } } } } + if a.FollowStandards && (cost < 4 || cost > 31) { + return nil, fmt.Errorf("bcrypt cost must be between 4 and 31") + } // Ensure salt is truncated to 22 bytes. if len(salt) > 22 { @@ -111,7 +116,7 @@ func (a *BCrypt) Hash(password []byte, salt []byte) ([]byte, error) { case 'b', 'y': // No bug, no safety hack needed (standard). default: - // Unknown minor version defaults to standard (safe). + return nil, fmt.Errorf("unsupported bcrypt minor version %q", minor) } c, err := a.setupBlowfishCipher(password, cost, salt, bug, safety) diff --git a/go.sum b/go.sum index a309815..b72c63d 100644 --- a/go.sum +++ b/go.sum @@ -2,16 +2,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/openwall/yescrypt-go v1.0.0 h1:jsGk48zkFvtUjGVOhYPGh+CS595JmTRcKnpggK2AON4= github.com/openwall/yescrypt-go v1.0.0/go.mod h1:e6CWtFizUEOUttaOjeVMiv1lJaJie3mfOtLJ9CCD6sA= -github.com/pedroalbanese/gogost v0.0.0-20240430171730-f95129c7a5af h1:8jbTN9e84FOzAJtCPdy/NEz8983YdD7nqTBMQlTRP4w= -github.com/pedroalbanese/gogost v0.0.0-20240430171730-f95129c7a5af/go.mod h1:A4x4C7B6z2POO1x5CZzKXZVCOFPfjzxxVUbWl2Thhp0= github.com/pedroalbanese/gogost v0.0.0-20250117160715-44a1f1ec2524 h1:L3ryWlHFZS6yPpv7f//uG15iU+f4IDOc2dzandl72Po= github.com/pedroalbanese/gogost v0.0.0-20250117160715-44a1f1ec2524/go.mod h1:A4x4C7B6z2POO1x5CZzKXZVCOFPfjzxxVUbWl2Thhp0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/passwd.go b/passwd.go index 8e0015c..e775967 100644 --- a/passwd.go +++ b/passwd.go @@ -42,11 +42,13 @@ type PasswdInterface interface { // Base structure. type Passwd struct { - Magic string - Params string - SaltLength int - Salt []byte - i PasswdInterface + Magic string + Params string + // When true, enforce libxcrypt-compatible parameter limits. + FollowStandards bool + SaltLength int + Salt []byte + i PasswdInterface } // Get a password interface based on hash settings string. @@ -330,6 +332,11 @@ func (a *Passwd) SetParams(p string) { a.Params = p } +// Enable or disable libxcrypt-compatible parameter checks. +func (a *Passwd) SetFollowStandards(v bool) { + a.FollowStandards = v +} + // Set a salt for hashing, an empty salt will generate a new one. func (a *Passwd) SetSalt(s []byte) { a.Salt = s diff --git a/passwd_test.go b/passwd_test.go index 88723d6..68a809e 100644 --- a/passwd_test.go +++ b/passwd_test.go @@ -81,3 +81,42 @@ func TestNewPasswordGeneration(t *testing.T) { }) } } + +func TestFollowStandards(t *testing.T) { + t.Run("DefaultIsDisabled", func(t *testing.T) { + p := NewSHA512CryptPasswd().(*SHA512Crypt) + p.SetParams("rounds=999") + + hash, err := p.SHashPasswordWithSalt("Test", "salt") + require.NoError(t, err) + assert.Contains(t, hash, "$6$rounds=999$salt$") + assert.False(t, p.FollowStandards) + }) + + t.Run("SHA512RoundsRejected", func(t *testing.T) { + p := NewSHA512CryptPasswd().(*SHA512Crypt) + p.SetParams("rounds=999") + p.FollowStandards = true + + _, err := p.SHashPasswordWithSalt("Test", "salt") + require.Error(t, err) + }) + + t.Run("BCryptCostRejected", func(t *testing.T) { + p := NewBCryptPasswd().(*BCrypt) + p.SetParams("b$03") + p.FollowStandards = true + + _, err := p.SHashPasswordWithSalt("Test", "abcdefghijklmnopqrstuu") + require.Error(t, err) + }) + + t.Run("SCryptNRejected", func(t *testing.T) { + p := NewSCryptPasswd().(*SCrypt) + require.NoError(t, p.SetSCryptParams(1, 1, 1)) + p.FollowStandards = true + + _, err := p.SHashPasswordWithSalt("Test", "abcdefghijklmnopqrstuv") + require.Error(t, err) + }) +} diff --git a/s_crypt.go b/s_crypt.go index 8a2f609..2247c65 100644 --- a/s_crypt.go +++ b/s_crypt.go @@ -46,6 +46,20 @@ func (a *SCrypt) DecodeSCryptParams() (N, r, p int) { // Hash a password with salt using scrypt standard. func (a *SCrypt) Hash(password []byte, salt []byte) (hash []byte, err error) { N, r, p := a.DecodeSCryptParams() + if r < 1 { + return nil, fmt.Errorf("scrypt r must be >= 1") + } + if p < 1 { + return nil, fmt.Errorf("scrypt p must be >= 1") + } + if a.FollowStandards { + if N < 2 || N > 63 { + return nil, fmt.Errorf("scrypt N must be between 2 and 63") + } + if r >= (1<<30) || p >= (1<<30) || uint64(r)*uint64(p) >= (1<<30) { + return nil, fmt.Errorf("scrypt requires r*p < 2^30") + } + } scryptHash, err := yescrypt.ScryptKey(password, salt, 1< 999999999) { + return nil, fmt.Errorf("rounds must be between 1000 and 999999999") + } } // Compute hash. diff --git a/sha512_crypt.go b/sha512_crypt.go index 8bb85ec..ad63803 100644 --- a/sha512_crypt.go +++ b/sha512_crypt.go @@ -133,6 +133,9 @@ func (a *SHA512Crypt) HashPasswordWithSalt(password []byte, salt []byte) (hash [ if err != nil { return } + if a.FollowStandards && (iterations < 1000 || iterations > 999999999) { + return nil, fmt.Errorf("rounds must be between 1000 and 999999999") + } } // Compute hash. diff --git a/sun_md5.go b/sun_md5.go index 1d90994..f976998 100644 --- a/sun_md5.go +++ b/sun_md5.go @@ -170,6 +170,9 @@ func (a *SunMD5) HashPasswordWithSalt(password []byte, salt []byte) (hash []byte if err != nil { return } + if a.FollowStandards && (iterations < 1 || iterations > 0xFFFFFFFF) { + return nil, fmt.Errorf("rounds must be between 1 and 4294967295") + } } // Compute hash.