168 lines
5.3 KiB
Go
168 lines
5.3 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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$<N>$<r>$<p>$<saltB64>$<hashB64>,
|
||
|
|
// 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)
|
||
|
|
}
|