package auth import ( "crypto/rand" "crypto/subtle" "encoding/base64" "errors" "fmt" "strings" "unicode" "golang.org/x/crypto/argon2" ) const ( argon2Time = 3 argon2Memory = 64 * 1024 argon2Threads = 4 argon2KeyLen = 32 argon2SaltLen = 16 minPasswordLength = 14 minPassphraseWords = 5 maxPasswordLength = 256 ) var ( ErrPasswordTooLong = errors.New("password must be at most 256 characters") ErrPasswordTooWeak = errors.New("password must be at least 14 characters or at least 5 words") ErrPasswordMatchesUser = errors.New("password cannot match the login name") ErrPasswordCommon = errors.New("password is too common") ErrPasswordNotPrintable = errors.New("password may only contain printable characters") ) // ValidatePassword checks password policy rules. func ValidatePassword(password string, username string) error { if len(password) > maxPasswordLength { return ErrPasswordTooLong } for _, runeValue := range password { if !unicode.IsPrint(runeValue) { return ErrPasswordNotPrintable } } if strings.EqualFold(strings.TrimSpace(password), strings.TrimSpace(username)) { return ErrPasswordMatchesUser } if isCommonPassword(password) { return ErrPasswordCommon } wordCount := countWords(password) if len(password) >= minPasswordLength || wordCount >= minPassphraseWords { return nil } return ErrPasswordTooWeak } func countWords(password string) int { fields := strings.Fields(password) return len(fields) } // HashPassword returns a PHC-formatted argon2id hash string. func HashPassword(password string) (string, error) { salt := make([]byte, argon2SaltLen) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("generate salt: %w", err) } hash := argon2.IDKey( []byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen, ) return fmt.Sprintf( "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argon2Memory, argon2Time, argon2Threads, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash), ), nil } // VerifyPassword compares password against a PHC argon2id hash. func VerifyPassword(password string, encodedHash string) (bool, error) { parts := strings.Split(encodedHash, "$") if len(parts) != 6 || parts[1] != "argon2id" { return false, errors.New("unsupported password hash format") } var version int if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { return false, errors.New("invalid hash version") } var memory uint32 var timeCost uint32 var threads uint8 if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil { return false, errors.New("invalid hash parameters") } salt, err := base64.RawStdEncoding.DecodeString(parts[4]) if err != nil { return false, errors.New("invalid hash salt") } expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5]) if err != nil { return false, errors.New("invalid hash digest") } computed := argon2.IDKey( []byte(password), salt, timeCost, memory, threads, uint32(len(expectedHash)), ) return subtle.ConstantTimeCompare(computed, expectedHash) == 1, nil }