// Package auth handles administrator credentials, API tokens, CSRF protection // and the HTTP middleware that enforces them. package auth import ( "crypto/rand" "crypto/subtle" "encoding/base64" "errors" "fmt" "runtime" "strings" "unicode" "golang.org/x/crypto/argon2" ) // Argon2id parameters. // // 64 MiB with three passes is the interactive profile from the Argon2 RFC: // costly enough that an offline attack on a stolen hash is expensive, cheap // enough that a login takes well under a second on the small machines this // server is meant to run on. const ( argonTime = 3 argonMemory = 64 * 1024 // KiB argonKeyLen = 32 argonSaltLen = 16 ) func argonThreads() uint8 { n := runtime.NumCPU() if n > 4 { n = 4 } if n < 1 { n = 1 } return uint8(n) } // ErrInvalidHash is returned when a stored hash cannot be parsed. var ErrInvalidHash = errors.New("stored password hash is malformed") // HashPassword derives an Argon2id hash in the standard PHC string format, so // the parameters travel with the hash and can be raised later without // invalidating existing credentials. func HashPassword(password string) (string, error) { if password == "" { return "", errors.New("password must not be empty") } salt := make([]byte, argonSaltLen) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("generate password salt: %w", err) } threads := argonThreads() key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, threads, argonKeyLen) return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, threads, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key), ), nil } // VerifyPassword checks a password against a stored PHC hash in constant time. func VerifyPassword(encoded, password string) (bool, error) { parts := strings.Split(encoded, "$") if len(parts) != 6 || parts[1] != "argon2id" { return false, ErrInvalidHash } var version int if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { return false, ErrInvalidHash } if version != argon2.Version { return false, fmt.Errorf("%w: unsupported Argon2 version %d", ErrInvalidHash, version) } var memory, time uint32 var threads uint8 if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { return false, ErrInvalidHash } salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[4]) if err != nil { return false, ErrInvalidHash } want, err := base64.RawStdEncoding.Strict().DecodeString(parts[5]) if err != nil { return false, ErrInvalidHash } got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want))) return subtle.ConstantTimeCompare(got, want) == 1, nil } // NeedsRehash reports whether a stored hash uses weaker parameters than the // current policy, so it can be upgraded on the next successful login. func NeedsRehash(encoded string) bool { parts := strings.Split(encoded, "$") if len(parts) != 6 || parts[1] != "argon2id" { return true } var memory, time uint32 var threads uint8 if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { return true } return memory < argonMemory || time < argonTime } // passwordAlphabet avoids characters that are easy to confuse when a generated // password is read off a terminal and typed into a browser. const passwordAlphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" // GeneratePassword returns a cryptographically random password. func GeneratePassword(length int) (string, error) { if length < 12 { length = 12 } buf := make([]byte, length) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("generate password: %w", err) } out := make([]byte, length) for i, b := range buf { out[i] = passwordAlphabet[int(b)%len(passwordAlphabet)] } return string(out), nil } // MinPasswordLength is the shortest password the UI will accept. const MinPasswordLength = 12 // ValidatePassword enforces a modest password policy. It follows current NIST // guidance: length carries the weight, and arbitrary composition rules are // avoided in favour of rejecting obviously weak choices. func ValidatePassword(password string) error { if len(password) < MinPasswordLength { return fmt.Errorf("password must be at least %d characters", MinPasswordLength) } if len(password) > 1024 { return errors.New("password must be at most 1024 characters") } for _, r := range password { if unicode.IsControl(r) { return errors.New("password must not contain control characters") } } lower := strings.ToLower(password) for _, weak := range []string{"password", "12345678", "qwerty", "vibedns", "changeme", "vibedns"} { if strings.Contains(lower, weak) { return fmt.Errorf("password must not contain the common string %q", weak) } } if isSingleRepeatedRune(password) { return errors.New("password must not be a single repeated character") } return nil } func isSingleRepeatedRune(s string) bool { if s == "" { return false } first := rune(s[0]) for _, r := range s { if r != first { return false } } return true }