coder/coderd/userpassword/userpassword.go

151 lines
4.3 KiB
Go

package userpassword
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"os"
"strconv"
"strings"
passwordvalidator "github.com/wagslane/go-password-validator"
"golang.org/x/crypto/pbkdf2"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/util/lazy"
)
var (
// The base64 encoder used when producing the string representation of
// hashes.
base64Encoding = base64.RawStdEncoding
// The number of iterations to use when generating the hash. This was chosen
// to make it about as fast as bcrypt hashes. Increasing this causes hashes
// to take longer to compute.
defaultHashIter = 65535
// This is the length of our output hash. bcrypt has a hash size of up to
// 60, so we rounded up to a power of 8.
hashLength = 64
// The scheme to include in our hashed password.
hashScheme = "pbkdf2-sha256"
// A salt size of 16 is the default in passlib. A minimum of 8 can be safely
// used.
defaultSaltSize = 16
// The simulated hash is used when trying to simulate password checks for
// users that don't exist. It's meant to preserve the timing of the hash
// comparison.
simulatedHash = lazy.New(func() string {
h, err := Hash("hunter2")
if err != nil {
panic(err)
}
return h
})
)
// Make password hashing much faster in tests.
func init() {
args := os.Args[1:]
// Ensure this can never be enabled if running in server mode.
if slices.Contains(args, "server") {
return
}
for _, flag := range args {
if strings.HasPrefix(flag, "-test.") {
defaultHashIter = 1
return
}
}
}
// Compare checks the equality of passwords from a hashed pbkdf2 string. This
// uses pbkdf2 to ensure FIPS 140-2 compliance. See:
// https://csrc.nist.gov/csrc/media/templates/cryptographic-module-validation-program/documents/security-policies/140sp2261.pdf
func Compare(hashed string, password string) (bool, error) {
// If the hased password provided is empty, simulate comparing a real hash.
if hashed == "" {
// TODO: this seems ripe for creating a vulnerability where
// hunter2 can log into any account.
hashed = simulatedHash.Load()
}
if len(hashed) < hashLength {
return false, xerrors.Errorf("hash too short: %d", len(hashed))
}
parts := strings.SplitN(hashed, "$", 5)
if len(parts) != 5 {
return false, xerrors.Errorf("hash has too many parts: %d", len(parts))
}
if len(parts[0]) != 0 {
return false, xerrors.Errorf("hash prefix is invalid")
}
if parts[1] != hashScheme {
return false, xerrors.Errorf("hash isn't %q scheme: %q", hashScheme, parts[1])
}
iter, err := strconv.Atoi(parts[2])
if err != nil {
return false, xerrors.Errorf("parse iter from hash: %w", err)
}
salt, err := base64Encoding.DecodeString(parts[3])
if err != nil {
return false, xerrors.Errorf("decode salt: %w", err)
}
if subtle.ConstantTimeCompare([]byte(hashWithSaltAndIter(password, salt, iter)), []byte(hashed)) != 1 {
return false, nil
}
return true, nil
}
// Hash generates a hash using pbkdf2.
// See the Compare() comment for rationale.
func Hash(password string) (string, error) {
salt := make([]byte, defaultSaltSize)
_, err := rand.Read(salt)
if err != nil {
return "", xerrors.Errorf("read random bytes for salt: %w", err)
}
return hashWithSaltAndIter(password, salt, defaultHashIter), nil
}
// Produces a string representation of the hash.
func hashWithSaltAndIter(password string, salt []byte, iter int) string {
var (
hash = pbkdf2.Key([]byte(password), salt, iter, hashLength, sha256.New)
encHash = make([]byte, base64Encoding.EncodedLen(len(hash)))
encSalt = make([]byte, base64Encoding.EncodedLen(len(salt)))
)
base64Encoding.Encode(encHash, hash)
base64Encoding.Encode(encSalt, salt)
return fmt.Sprintf("$%s$%d$%s$%s", hashScheme, iter, encSalt, encHash)
}
// Validate checks that the plain text password meets the minimum password requirements.
// It returns properly formatted errors for detailed form validation on the client.
func Validate(password string) error {
// Ensure passwords are secure enough!
// See: https://github.com/wagslane/go-password-validator#what-entropy-value-should-i-use
err := passwordvalidator.Validate(password, 52)
if err != nil {
return err
}
if len(password) > 64 {
return xerrors.Errorf("password must be no more than %d characters", 64)
}
return nil
}