All checks were successful
check / check (push) Successful in 2m46s
In the shipped default, any stranger denied the operator the only administrative path at 5 requests per minute: TRUSTED_PROXIES is empty, the README requires a reverse proxy, so every login POST shared one bucket keyed on the proxy. Credentials are now verified first and only a FAILED attempt spends budget, so a correct password is never throttled. Failures are counted per (client bucket, submitted username), bounded. Concurrent Argon2id verifications are capped at two, and the queue for them at 16 — because verifying first lets an attacker force a 64 MB hash per request, and bounding the wait alone bounds nothing. The issue's own recommendation was insufficient and is rejected here: keying by username stops an attacker locking out a DIFFERENT account, but this is a single-admin product with a predictable bootstrap username, so flooding the operator's own name still locks them out. This is speculative — it implements a corrected recommendation ahead of the owner's ruling so the decision can be made by merging or reverting. Three things are disclosed rather than glossed: online guessing rises from 5/min to roughly 27/s, because the 429 is a label on the response and not a gate in front of the hash; the residual exposure is a loss of login AVAILABILITY, not latency, and a determined flood still denies login while it runs, at ~400x the cost and clearing the moment it stops; and the endpoint should be provisioned for ~400 MB resident, not the 203 MB of live commitment it itemises. Independently reviewed four times. Reviewers disproved the suspected FIFO starvation by measurement, then caught two successive memory bounds the code did not have — the second by parking waiters and reading the heap rather than checking the arithmetic.
321 lines
7.7 KiB
Go
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())
|
|
}
|