Files
webhooker/internal/database/password.go
sneak fad97445ca
All checks were successful
check / check (push) Successful in 2m54s
Verify login credentials before spending rate-limit budget (closes #150)
With TRUSTED_PROXIES empty behind the reverse proxy production is
required to run behind, every login POST keyed on the proxy's address
and shared one 5/minute bucket. A stranger sending five POSTs a
minute -- 0.08 requests per second, from anywhere -- kept that bucket
permanently full, and the operator's own correct password was answered
429 indefinitely with no second administrative path.

The login POST no longer has a pre-emptive limiter. The handler
verifies credentials first and spends budget only on a FAILED attempt,
so a correct password is never throttled whatever the counters hold.
Three things follow, and are implemented together because the first is
unsafe without the other two:

- Failures are counted per (client bucket, submitted username), five
  per minute, after which further failures get 429 with a Retry-After.
  A successful login clears the counter, so mistyping and then
  succeeding does not leave the operator throttled.
- Both key sets are capped at 1024 entries. The submitted username is
  attacker-controlled, so past the first cap failures fall back to a
  counter keyed on the client alone, and past both caps a failure is
  answered as throttled without being recorded. Tracked state stays
  under half a megabyte and does not grow with invented usernames.
- Concurrent Argon2id verifications are capped at two, a 128 MB
  ceiling at 64 MB per hash. Every password-hashing endpoint takes a
  slot, including the password-change endpoint, which holds one across
  both its hashes. A request that waits five seconds without a slot is
  answered 503 and no hash runs for it.

An unknown username is verified against a dummy hash instead of
returning early, so a nonexistent account costs the same time as a
real one and the response cannot be used to enumerate usernames.

The password-change limiter is unchanged: RequireAuth runs ahead of
it, so only a request already carrying a valid session reaches its
bucket.

Also adds the missing test for the third bucketKey call site, where
the peer is a trusted proxy but the forwarded chain names no client.
Every existing test of that fallback uses an IPv4 proxy, where
bucketKey is the identity function, so dropping the /64 masking there
left the suite green.

README and the TRUSTED_PROXIES startup warning updated: a shared
bucket now costs precision, not the availability of the admin path.
2026-08-17 22:17:49 +00:00

321 lines
7.7 KiB
Go

package database
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"math/big"
"strings"
"sync"
"golang.org/x/crypto/argon2"
)
// Argon2 parameters - these are up-to-date secure defaults
const (
argon2Time = 1
argon2Memory = 64 * 1024 // 64 MB
argon2Threads = 4
argon2KeyLen = 32
argon2SaltLen = 16
)
// hashParts is the expected number of $-separated segments
// in an encoded Argon2id hash string.
const hashParts = 6
// minPasswordComplexityLen is the minimum password length that
// triggers per-character-class complexity enforcement.
const minPasswordComplexityLen = 4
// dummyPasswordLen is the length of the throwaway password behind
// dummyPasswordHash.
const dummyPasswordLen = 32
// Sentinel errors returned by decodeHash.
var (
errInvalidHashFormat = errors.New("invalid hash format")
errInvalidAlgorithm = errors.New("invalid algorithm")
errIncompatibleVersion = errors.New("incompatible argon2 version")
errSaltLengthOutOfRange = errors.New("salt length out of range")
errHashLengthOutOfRange = errors.New("hash length out of range")
)
// PasswordConfig holds Argon2 configuration
type PasswordConfig struct {
Time uint32
Memory uint32
Threads uint8
KeyLen uint32
SaltLen uint32
}
// DefaultPasswordConfig returns secure default Argon2 parameters
func DefaultPasswordConfig() *PasswordConfig {
return &PasswordConfig{
Time: argon2Time,
Memory: argon2Memory,
Threads: argon2Threads,
KeyLen: argon2KeyLen,
SaltLen: argon2SaltLen,
}
}
// HashPassword generates an Argon2id hash of the password
func HashPassword(password string) (string, error) {
config := DefaultPasswordConfig()
// Generate a salt
salt := make([]byte, config.SaltLen)
_, err := rand.Read(salt)
if err != nil {
return "", err
}
// Generate the hash
hash := argon2.IDKey(
[]byte(password),
salt,
config.Time,
config.Memory,
config.Threads,
config.KeyLen,
)
// Encode the hash and parameters
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
// Format: $argon2id$v=19$m=65536,t=1,p=4$salt$hash
encoded := fmt.Sprintf(
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
config.Memory,
config.Time,
config.Threads,
b64Salt,
b64Hash,
)
return encoded, nil
}
// VerifyPassword checks if the provided password matches the hash
func VerifyPassword(
password, encodedHash string,
) (bool, error) {
// Extract parameters and hash from encoded string
config, salt, hash, err := decodeHash(encodedHash)
if err != nil {
return false, err
}
// Generate hash of the provided password
otherHash := argon2.IDKey(
[]byte(password),
salt,
config.Time,
config.Memory,
config.Threads,
config.KeyLen,
)
// Compare hashes using constant time comparison
return subtle.ConstantTimeCompare(hash, otherHash) == 1, nil
}
// dummyPasswordHash is an encoded Argon2id hash of a random
// password, computed once on first use. Nothing can match it: the
// password it encodes is discarded as soon as it is hashed. It is
// process-wide because building it per request would add a second
// 64 MB Argon2id pass to every login for an unknown username.
//
//nolint:gochecknoglobals // computed once, see above
var dummyPasswordHash = sync.OnceValue(func() string {
password, err := GenerateRandomPassword(dummyPasswordLen)
if err != nil {
panic(fmt.Sprintf("generating the dummy password: %v", err))
}
hash, err := HashPassword(password)
if err != nil {
panic(fmt.Sprintf("hashing the dummy password: %v", err))
}
return hash
})
// VerifyDummyPassword performs a credential verification that cannot
// succeed, at the same cost as a real one.
//
// Login must charge an unknown username the same work as a known
// one. Returning early for an account that does not exist answers in
// microseconds where a real account takes tens of milliseconds, which
// is a username oracle any client can read off the response time.
func VerifyDummyPassword(password string) {
_, _ = VerifyPassword(password, dummyPasswordHash())
}
// decodeHash extracts parameters, salt, and hash from an
// encoded hash string.
func decodeHash(
encodedHash string,
) (*PasswordConfig, []byte, []byte, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != hashParts {
return nil, nil, nil, errInvalidHashFormat
}
if parts[1] != "argon2id" {
return nil, nil, nil, errInvalidAlgorithm
}
version, err := parseVersion(parts[2])
if err != nil {
return nil, nil, nil, err
}
if version != argon2.Version {
return nil, nil, nil, errIncompatibleVersion
}
config, err := parseParams(parts[3])
if err != nil {
return nil, nil, nil, err
}
salt, err := decodeSalt(parts[4])
if err != nil {
return nil, nil, nil, err
}
config.SaltLen = uint32(len(salt)) //nolint:gosec // validated in decodeSalt
hash, err := decodeHashBytes(parts[5])
if err != nil {
return nil, nil, nil, err
}
config.KeyLen = uint32(len(hash)) //nolint:gosec // validated in decodeHashBytes
return config, salt, hash, nil
}
func parseVersion(s string) (int, error) {
var version int
_, err := fmt.Sscanf(s, "v=%d", &version)
if err != nil {
return 0, fmt.Errorf("parsing version: %w", err)
}
return version, nil
}
func parseParams(s string) (*PasswordConfig, error) {
config := &PasswordConfig{}
_, err := fmt.Sscanf(
s, "m=%d,t=%d,p=%d",
&config.Memory, &config.Time, &config.Threads,
)
if err != nil {
return nil, fmt.Errorf("parsing params: %w", err)
}
return config, nil
}
func decodeSalt(s string) ([]byte, error) {
salt, err := base64.RawStdEncoding.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("decoding salt: %w", err)
}
saltLen := len(salt)
if saltLen < 0 || saltLen > int(^uint32(0)) {
return nil, errSaltLengthOutOfRange
}
return salt, nil
}
func decodeHashBytes(s string) ([]byte, error) {
hash, err := base64.RawStdEncoding.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("decoding hash: %w", err)
}
hashLen := len(hash)
if hashLen < 0 || hashLen > int(^uint32(0)) {
return nil, errHashLengthOutOfRange
}
return hash, nil
}
// GenerateRandomPassword generates a cryptographically secure
// random password.
func GenerateRandomPassword(length int) (string, error) {
const (
uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
lowercase = "abcdefghijklmnopqrstuvwxyz"
digits = "0123456789"
special = "!@#$%^&*()_+-=[]{}|;:,.<>?"
)
// Combine all character sets
allChars := uppercase + lowercase + digits + special
// Create password slice
password := make([]byte, length)
// Ensure at least one character from each set
if length >= minPasswordComplexityLen {
password[0] = uppercase[cryptoRandInt(len(uppercase))]
password[1] = lowercase[cryptoRandInt(len(lowercase))]
password[2] = digits[cryptoRandInt(len(digits))]
password[3] = special[cryptoRandInt(len(special))]
// Fill the rest randomly from all characters
for i := minPasswordComplexityLen; i < length; i++ {
password[i] = allChars[cryptoRandInt(len(allChars))]
}
// Shuffle the password to avoid predictable pattern
for i := range len(password) - 1 {
j := cryptoRandInt(len(password) - i)
idx := len(password) - 1 - i
password[idx], password[j] = password[j], password[idx]
}
} else {
// For very short passwords, just use all characters
for i := range length {
password[i] = allChars[cryptoRandInt(len(allChars))]
}
}
return string(password), nil
}
// cryptoRandInt generates a cryptographically secure random
// integer in [0, upperBound).
func cryptoRandInt(upperBound int) int {
if upperBound <= 0 {
panic("upperBound must be positive")
}
nBig, err := rand.Int(
rand.Reader,
big.NewInt(int64(upperBound)),
)
if err != nil {
panic(fmt.Sprintf("crypto/rand error: %v", err))
}
return int(nBig.Int64())
}