Files
webhooker/internal/database/password.go
sneak b261a52515
All checks were successful
check / check (push) Successful in 2m47s
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, and the queue for those slots is capped
  at 16 waiters. 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 one that arrives with the queue already full is
  shed with 503 immediately rather than joining it. Bounding the wait
  alone would not bound memory, and the queue depth is sized from what
  a parked waiter measurably retains rather than from the 1 MB body
  cap, which bounds only the raw body read. The body-cap, CSRF and
  form-parsing middleware all run before the guard, so a waiter holds
  its parsed form plus its request header block for the whole wait.
  Measured on the pinned go1.26.1 toolchain as the HeapAlloc delta
  across two GCs with 64 waiters parked in the handler: an ordinary
  two-field login form retains ~0 MB, a 1 MB urlencoded body at Go's
  10,000-parameter parse cap retains 2.82 MB (3.09 MB with %41
  escapes), and the ~0.9 MB of headers the 1 MB header cap allows
  takes it to 4.18 MB. The retained parse and the header block
  dominate, not the raw body. So 16 waiters: 16 x 4.18 MB is about
  67 MB of committed queue memory, and two slots drain a full 16-deep
  queue in about 0.6 s, far inside the deadline. Peak commitment for
  the endpoint is about 203 MB — 128 MB of Argon2id plus the 18
  requests holding a parsed form, 16 queued and the 2 being hashed, at
  about 75 MB.

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.

Two consequences are documented rather than fixed, because they follow
from the shape the issue asks for. Online guessing throughput rises
from 5 a minute to roughly 27 a second, about 2.3 million a day: the
credential check always precedes the counter, so the 429 is a label on
the response rather than a gate in front of the hash, and what bounds
brute force is the semaphore. And under a sustained flood the residual
exposure is a loss of login availability, not merely of latency --
above about 27 requests a second most attempts are shed with 503, so a
determined flood still denies login for as long as it runs. It costs
roughly 400x more to run, nothing accumulates, and the first attempt
after it stops succeeds. Restarting the service does not help: the
counters a restart clears are not what is saturated.

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 23:24:36 +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())
}