Files

247 lines
7.4 KiB
Go

// 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)
}