package auth import ( "errors" "sync" "golang.org/x/crypto/bcrypt" ) const ( minPassLen = 8 maxPassLen = 72 // bcrypt silently truncates beyond 72 bytes ) // bcryptCost is a var (not a const) so SetCostForTesting can lower it in test // binaries. Production code never mutates it. var bcryptCost = 12 // SetCostForTesting lowers the bcrypt cost for the current process and resets // the dummy timing pad so it is regenerated at the new cost. Intended to be // called from TestMain with bcrypt.MinCost: password hashing dominates the // api/admin test suites (~264 cost-12 hashes ≈ minutes of pure bcrypt), and // nothing about the tests depends on the hash strength. func SetCostForTesting(cost int) { bcryptCost = cost dummyHashOnce = sync.Once{} dummyHash = nil } // ErrPasswordTooShort is returned when the password is below the minimum length. var ErrPasswordTooShort = errors.New("password must be at least 8 characters") // ErrPasswordTooLong is returned when the password exceeds bcrypt's 72-byte limit. var ErrPasswordTooLong = errors.New("password must not exceed 72 characters") // HashPassword returns a bcrypt hash of password using cost 12. func HashPassword(password string) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) if err != nil { return "", err } return string(hash), nil } // dummyHash is a lazily-computed bcrypt hash used to prevent timing // side-channels when the user does not exist. Comparing against this dummy // ensures that CheckPassword takes roughly constant time regardless of // whether a valid hash was supplied. var ( dummyHash []byte dummyHashOnce sync.Once ) // getDummyHash returns the pre-computed dummy bcrypt hash, initialising it // on first call via sync.Once. func getDummyHash() []byte { dummyHashOnce.Do(func() { h, err := bcrypt.GenerateFromPassword([]byte("dummy-timing-pad"), bcryptCost) if err != nil { // crypto/rand is required for the server to function; panic is // appropriate here as there is no recovery path. panic("auth: failed to generate dummy bcrypt hash: " + err.Error()) } dummyHash = h }) return dummyHash } // CheckPassword reports whether password matches hash. Returns false on any // error, including an empty or malformed hash. When hash is empty (user does // not exist), a dummy bcrypt comparison is performed to prevent timing-based // username enumeration. func CheckPassword(hash, password string) bool { if hash == "" { // Perform a dummy comparison so the response time is indistinguishable // from a real check, preventing timing-based username enumeration. // The error is intentionally discarded: we always return false here. // The comparison is performed only to consume time and prevent // timing-based username enumeration. _ = bcrypt.CompareHashAndPassword(getDummyHash(), []byte(password)) return false } err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil } // ValidatePasswordStrength returns an error if password fails strength // requirements: minimum 8 characters, maximum 72 characters. func ValidatePasswordStrength(password string) error { if len(password) < minPassLen { return ErrPasswordTooShort } if len(password) > maxPassLen { return ErrPasswordTooLong } return nil }