// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Package password hashes and verifies passwords with scrypt. // // A fixed set of scrypt parameters (N, r, p, dklen, salt length) is // enforced so weaker configurations stored in older users.toml files are // rejected. Stored hashes use scrypt$$$

$$, // the encoding every released version has written, so an existing // users.toml keeps working unchanged. package password import ( "crypto/rand" "crypto/subtle" "encoding/base64" "errors" "fmt" "log/slog" "strconv" "strings" "sync" "golang.org/x/crypto/scrypt" ) const ( // CostN, BlockR, ParallelP and KeyLength are the scrypt policy floor. CostN = 16384 BlockR = 8 ParallelP = 1 KeyLength = 32 SaltBytes = 16 prefix = "scrypt" maxMemBytes = 64 << 20 // bound scrypt memory for hostile stored hashes maxWorkBits = 1 << 28 // bound the full 128*N*r*p work: p multiplies CPU, not memory ) // ErrEmpty is returned when the password to hash is empty. var ErrEmpty = errors.New("password must not be empty") // MaxPasswordLength caps input size to bound scrypt work. const MaxPasswordLength = 1024 // dummy is a hash of an unguessable value, derived on first use. var dummy = sync.OnceValue(func() string { salt := make([]byte, SaltBytes) rand.Read(salt) derived, err := scrypt.Key(salt, salt, CostN, BlockR, ParallelP, KeyLength) if err != nil { return "" } return fmt.Sprintf("%s$%d$%d$%d$%s$%s", prefix, CostN, BlockR, ParallelP, base64.StdEncoding.EncodeToString(salt), base64.StdEncoding.EncodeToString(derived), ) }) // Dummy returns a valid encoded hash of a value nobody knows, for // verification against when the username does not exist: a caller can // spend the same scrypt work either way, so the response time does not // reveal whether an account exists. func Dummy() string { return dummy() } // Hash derives a scrypt hash and returns the encoded string. func Hash(password string) (string, error) { if password == "" { return "", ErrEmpty } if len([]rune(password)) > MaxPasswordLength { return "", fmt.Errorf("password longer than %d characters", MaxPasswordLength) } salt := make([]byte, SaltBytes) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("generate salt: %w", err) } derived, err := scrypt.Key([]byte(password), salt, CostN, BlockR, ParallelP, KeyLength) if err != nil { return "", fmt.Errorf("scrypt hash: %w", err) } return fmt.Sprintf("%s$%d$%d$%d$%s$%s", prefix, CostN, BlockR, ParallelP, base64.StdEncoding.EncodeToString(salt), base64.StdEncoding.EncodeToString(derived), ), nil } // Verify checks a password against an encoded scrypt hash in constant // time. Hashes produced with parameters below the policy floor, or that // are malformed, are rejected with false and a warning is logged. func Verify(password, encoded string) bool { n, r, p, salt, expected, ok := parse(encoded) if !ok { slog.Warn("password verify: malformed stored hash") return false } if n < CostN || r < BlockR || p < ParallelP || len(expected) < KeyLength { slog.Warn("password verify: stored hash uses weak scrypt parameters, rejecting", "n", n, "r", r, "p", p, "dklen", len(expected)) return false } if scryptMem(n, r) > maxMemBytes { slog.Warn("password verify: stored hash exceeds memory bound, rejecting", "n", n, "r", r) return false } // p multiplies the sequential work without touching the memory bound, // so it needs its own ceiling: a hostile file with a huge p would // otherwise burn hours of CPU inside a single verification. if p < 1 || int64(p) > maxWorkBits/(128*int64(n)*int64(r)) { slog.Warn("password verify: stored hash exceeds work bound, rejecting", "n", n, "r", r, "p", p) return false } derived, err := scrypt.Key([]byte(password), salt, n, r, p, len(expected)) if err != nil { slog.Warn("password verify: scrypt failed", "error", err) return false } return subtle.ConstantTimeCompare(derived, expected) == 1 } // NeedsRehash reports whether stored uses parameters the policy floor no // longer accepts: Verify rejects such a hash, so the account cannot sign // in until its password is reset out of band (users.toml or a new hash // from the operator). Re-hashing on login is not possible, because the // weak verification that would allow it is exactly what the floor forbids. func NeedsRehash(stored string) bool { n, r, p, _, expected, ok := parse(stored) if !ok { return true } return n < CostN || r < BlockR || p < ParallelP || len(expected) < KeyLength } func parse(encoded string) (n, r, p int, salt, hash []byte, ok bool) { parts := strings.Split(encoded, "$") if len(parts) != 6 || parts[0] != prefix { return 0, 0, 0, nil, nil, false } n, err := strconv.Atoi(parts[1]) if err != nil { return 0, 0, 0, nil, nil, false } r, err = strconv.Atoi(parts[2]) if err != nil { return 0, 0, 0, nil, nil, false } p, err = strconv.Atoi(parts[3]) if err != nil { return 0, 0, 0, nil, nil, false } salt, err = base64.StdEncoding.DecodeString(parts[4]) if err != nil { return 0, 0, 0, nil, nil, false } hash, err = base64.StdEncoding.DecodeString(parts[5]) if err != nil { return 0, 0, 0, nil, nil, false } return n, r, p, salt, hash, true } func scryptMem(n, r int) int64 { return 128 * int64(n) * int64(r) }