fix: harden credential and input handling
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
// Package auth implements local authentication: Argon2id password hashing,
|
||||
// server-side sessions, CSRF protection and login rate limiting.
|
||||
//
|
||||
// Plain-text passwords are never stored, never logged and never compared with
|
||||
// anything other than a constant-time function.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Argon2 parameters.
|
||||
//
|
||||
// These target roughly 64 MiB and a few hundred milliseconds on a modern
|
||||
// machine — deliberately expensive, because the whole point is to make an
|
||||
// offline guessing attack against a stolen hash uneconomic. The parameters are
|
||||
// stored in each hash, so raising them later does not invalidate existing
|
||||
// passwords: an old hash still verifies with its own parameters.
|
||||
const (
|
||||
argonTime uint32 = 3
|
||||
argonMemory uint32 = 64 * 1024 // KiB
|
||||
argonKeyLen uint32 = 32
|
||||
argonSaltLen = 16
|
||||
|
||||
// Stored hashes are data, not trusted instructions. These generous ceilings
|
||||
// prevent a corrupt or attacker-modified database value from turning login
|
||||
// into an extreme allocation or CPU request while leaving room for upgrades.
|
||||
maxEncodedHashLen = 512
|
||||
maxArgonMemory uint32 = 256 * 1024 // KiB
|
||||
maxArgonTime uint32 = 10
|
||||
maxArgonParallelism = 16
|
||||
maxArgonSaltLen = 64
|
||||
maxArgonKeyLen = 64
|
||||
)
|
||||
|
||||
// Password policy.
|
||||
const (
|
||||
// MinPasswordLength is deliberately generous rather than demanding
|
||||
// character classes: length beats composition rules for real strength.
|
||||
MinPasswordLength = 12
|
||||
MaxPasswordLength = 1024
|
||||
MinUsernameLength = 3
|
||||
MaxUsernameLength = 64
|
||||
)
|
||||
|
||||
// Errors returned by password handling.
|
||||
var (
|
||||
ErrPasswordTooShort = fmt.Errorf("password must be at least %d characters", MinPasswordLength)
|
||||
ErrPasswordTooLong = fmt.Errorf("password must be at most %d characters", MaxPasswordLength)
|
||||
ErrInvalidHashFormat = errors.New("stored password hash is not in the expected format")
|
||||
ErrIncompatibleHash = errors.New("stored password hash uses an unsupported algorithm version")
|
||||
ErrInvalidUsername = errors.New("username must be 3-64 characters using letters, digits, dot, dash or underscore")
|
||||
)
|
||||
|
||||
// argonParallelism picks a lane count from the machine's CPUs, bounded so that
|
||||
// a hash produced on a large host still verifies affordably on a small one.
|
||||
func argonParallelism() uint8 {
|
||||
n := runtime.NumCPU()
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if n > 4 {
|
||||
n = 4
|
||||
}
|
||||
return uint8(n)
|
||||
}
|
||||
|
||||
// HashPassword returns an encoded Argon2id hash in the standard PHC string
|
||||
// format, which embeds the algorithm, its parameters and the salt.
|
||||
func HashPassword(password string) (string, error) {
|
||||
if err := ValidatePassword(password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
salt := make([]byte, argonSaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("generate salt: %w", err)
|
||||
}
|
||||
|
||||
par := argonParallelism()
|
||||
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, par, argonKeyLen)
|
||||
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argonMemory, argonTime, par,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(key),
|
||||
), nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks a candidate password against an encoded hash.
|
||||
//
|
||||
// The comparison is constant-time so that verification duration cannot reveal
|
||||
// how much of the hash matched.
|
||||
func VerifyPassword(password, encoded string) (bool, error) {
|
||||
params, salt, want, err := decodeHash(encoded)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(password) > MaxPasswordLength {
|
||||
return false, ErrPasswordTooLong
|
||||
}
|
||||
|
||||
got := argon2.IDKey([]byte(password), salt, params.time, params.memory, params.parallelism, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||
}
|
||||
|
||||
type argonParams struct {
|
||||
memory uint32
|
||||
time uint32
|
||||
parallelism uint8
|
||||
}
|
||||
|
||||
func decodeHash(encoded string) (argonParams, []byte, []byte, error) {
|
||||
var p argonParams
|
||||
if len(encoded) == 0 || len(encoded) > maxEncodedHashLen {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
|
||||
parts := strings.Split(encoded, "$")
|
||||
// "", "argon2id", "v=19", "m=...,t=...,p=...", salt, key
|
||||
if len(parts) != 6 || parts[0] != "" {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
if parts[1] != "argon2id" {
|
||||
return p, nil, nil, ErrIncompatibleHash
|
||||
}
|
||||
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
if version != argon2.Version {
|
||||
return p, nil, nil, ErrIncompatibleHash
|
||||
}
|
||||
|
||||
kv := strings.Split(parts[3], ",")
|
||||
if len(kv) != 3 {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
for _, item := range kv {
|
||||
k, v, ok := strings.Cut(item, "=")
|
||||
if !ok {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
switch k {
|
||||
case "m":
|
||||
p.memory = uint32(n)
|
||||
case "t":
|
||||
p.time = uint32(n)
|
||||
case "p":
|
||||
if n == 0 || n > 255 {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
p.parallelism = uint8(n)
|
||||
default:
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
}
|
||||
// Reject nonsensical parameters that would make verification trivial or
|
||||
// impossibly expensive.
|
||||
if p.memory < 1024 || p.memory > maxArgonMemory ||
|
||||
p.time == 0 || p.time > maxArgonTime ||
|
||||
p.parallelism == 0 || p.parallelism > maxArgonParallelism {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil || len(salt) < argonSaltLen || len(salt) > maxArgonSaltLen {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
key, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil || len(key) < 16 || len(key) > maxArgonKeyLen {
|
||||
return p, nil, nil, ErrInvalidHashFormat
|
||||
}
|
||||
return p, salt, key, nil
|
||||
}
|
||||
|
||||
// ValidatePassword enforces the password policy.
|
||||
func ValidatePassword(password string) error {
|
||||
n := utf8.RuneCountInString(password)
|
||||
if n < MinPasswordLength {
|
||||
return ErrPasswordTooShort
|
||||
}
|
||||
if len(password) > MaxPasswordLength {
|
||||
return ErrPasswordTooLong
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateUsername enforces the username policy and returns the canonical
|
||||
// lowercase form.
|
||||
func ValidateUsername(username string) (string, error) {
|
||||
u := strings.ToLower(strings.TrimSpace(username))
|
||||
if len(u) < MinUsernameLength || len(u) > MaxUsernameLength {
|
||||
return "", ErrInvalidUsername
|
||||
}
|
||||
for _, r := range u {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z',
|
||||
r >= '0' && r <= '9',
|
||||
r == '.', r == '-', r == '_':
|
||||
default:
|
||||
return "", ErrInvalidUsername
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// dummyHash is a real Argon2id hash of a random value, used to spend
|
||||
// comparable time when a username does not exist.
|
||||
//
|
||||
// Without it, a failed lookup would return noticeably faster than a wrong
|
||||
// password, and the response time would disclose which usernames are
|
||||
// registered.
|
||||
var dummyHash = func() string {
|
||||
buf := make([]byte, 32)
|
||||
_, _ = rand.Read(buf)
|
||||
h, err := HashPassword(base64.RawStdEncoding.EncodeToString(buf))
|
||||
if err != nil {
|
||||
// Only reachable if the policy rejects a 43-character random string.
|
||||
return "$argon2id$v=19$m=65536,t=3,p=2$YWJjZGVmZ2hpamtsbW5vcA$" +
|
||||
"c29tZWZpeGVkdmFsdWV0aGF0d2lsbG5ldmVybWF0Y2g"
|
||||
}
|
||||
return h
|
||||
}()
|
||||
|
||||
// SpendDummyVerification performs a password verification against a throwaway
|
||||
// hash. Callers use it on the "no such user" path so that both outcomes cost
|
||||
// roughly the same.
|
||||
func SpendDummyVerification(password string) {
|
||||
_, _ = VerifyPassword(password, dummyHash)
|
||||
}
|
||||
Reference in New Issue
Block a user