chatdesk-ui/auth_v2.169.0/internal/crypto/password.go

419 lines
14 KiB
Go

package crypto
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/supabase/auth/internal/observability"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/scrypt"
)
type HashCost = int
const (
// DefaultHashCost represents the default
// hashing cost for any hashing algorithm.
DefaultHashCost HashCost = iota
// QuickHashCosts represents the quickest
// hashing cost for any hashing algorithm,
// useful for tests only.
QuickHashCost HashCost = iota
Argon2Prefix = "$argon2"
FirebaseScryptPrefix = "$fbscrypt"
FirebaseScryptKeyLen = 32 // Firebase uses AES-256 which requires 32 byte keys: https://pkg.go.dev/golang.org/x/crypto/scrypt#Key
)
// PasswordHashCost is the current pasword hashing cost
// for all new hashes generated with
// GenerateHashFromPassword.
var PasswordHashCost = DefaultHashCost
var (
generateFromPasswordSubmittedCounter = observability.ObtainMetricCounter("gotrue_generate_from_password_submitted", "Number of submitted GenerateFromPassword hashing attempts")
generateFromPasswordCompletedCounter = observability.ObtainMetricCounter("gotrue_generate_from_password_completed", "Number of completed GenerateFromPassword hashing attempts")
)
var (
compareHashAndPasswordSubmittedCounter = observability.ObtainMetricCounter("gotrue_compare_hash_and_password_submitted", "Number of submitted CompareHashAndPassword hashing attempts")
compareHashAndPasswordCompletedCounter = observability.ObtainMetricCounter("gotrue_compare_hash_and_password_completed", "Number of completed CompareHashAndPassword hashing attempts")
)
var ErrArgon2MismatchedHashAndPassword = errors.New("crypto: argon2 hash and password mismatch")
var ErrScryptMismatchedHashAndPassword = errors.New("crypto: fbscrypt hash and password mismatch")
// argon2HashRegexp https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md#argon2-encoding
var argon2HashRegexp = regexp.MustCompile("^[$](?P<alg>argon2(d|i|id))[$]v=(?P<v>(16|19))[$]m=(?P<m>[0-9]+),t=(?P<t>[0-9]+),p=(?P<p>[0-9]+)(,keyid=(?P<keyid>[^,$]+))?(,data=(?P<data>[^$]+))?[$](?P<salt>[^$]*)[$](?P<hash>.*)$")
var fbscryptHashRegexp = regexp.MustCompile(`^\$fbscrypt\$v=(?P<v>[0-9]+),n=(?P<n>[0-9]+),r=(?P<r>[0-9]+),p=(?P<p>[0-9]+)(?:,ss=(?P<ss>[^,]+))?(?:,sk=(?P<sk>[^$]+))?\$(?P<salt>[^$]+)\$(?P<hash>.+)$`)
type Argon2HashInput struct {
alg string
v string
memory uint64
time uint64
threads uint64
keyid string
data string
salt []byte
rawHash []byte
}
type FirebaseScryptHashInput struct {
v string
memory uint64
rounds uint64
threads uint64
saltSeparator []byte
signerKey []byte
salt []byte
rawHash []byte
}
// See: https://github.com/firebase/scrypt for implementation
func ParseFirebaseScryptHash(hash string) (*FirebaseScryptHashInput, error) {
submatch := fbscryptHashRegexp.FindStringSubmatchIndex(hash)
if submatch == nil {
return nil, errors.New("crypto: incorrect scrypt hash format")
}
v := string(fbscryptHashRegexp.ExpandString(nil, "$v", hash, submatch))
n := string(fbscryptHashRegexp.ExpandString(nil, "$n", hash, submatch))
r := string(fbscryptHashRegexp.ExpandString(nil, "$r", hash, submatch))
p := string(fbscryptHashRegexp.ExpandString(nil, "$p", hash, submatch))
ss := string(fbscryptHashRegexp.ExpandString(nil, "$ss", hash, submatch))
sk := string(fbscryptHashRegexp.ExpandString(nil, "$sk", hash, submatch))
saltB64 := string(fbscryptHashRegexp.ExpandString(nil, "$salt", hash, submatch))
hashB64 := string(fbscryptHashRegexp.ExpandString(nil, "$hash", hash, submatch))
if v != "1" {
return nil, fmt.Errorf("crypto: Firebase scrypt hash uses unsupported version %q only version 1 is supported", v)
}
memoryPower, err := strconv.ParseUint(n, 10, 32)
if err != nil {
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid n parameter %q %w", n, err)
}
if memoryPower == 0 {
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid n=0")
}
rounds, err := strconv.ParseUint(r, 10, 64)
if err != nil {
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid r parameter %q: %w", r, err)
}
if rounds == 0 {
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid r=0")
}
threads, err := strconv.ParseUint(p, 10, 8)
if err != nil {
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid p parameter %q %w", p, err)
}
if threads == 0 {
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid p=0")
}
rawHash, err := base64.StdEncoding.DecodeString(hashB64)
if err != nil {
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid base64 in the hash section %w", err)
}
salt, err := base64.StdEncoding.DecodeString(saltB64)
if err != nil {
return nil, fmt.Errorf("crypto: Firebase scrypt salt has invalid base64 in the hash section %w", err)
}
var saltSeparator, signerKey []byte
if signerKey, err = base64.StdEncoding.DecodeString(sk); err != nil {
return nil, err
}
if saltSeparator, err = base64.StdEncoding.DecodeString(ss); err != nil {
return nil, err
}
input := &FirebaseScryptHashInput{
v: v,
memory: uint64(1) << memoryPower,
rounds: rounds,
threads: threads,
salt: salt,
rawHash: rawHash,
saltSeparator: saltSeparator,
signerKey: signerKey,
}
return input, nil
}
func ParseArgon2Hash(hash string) (*Argon2HashInput, error) {
submatch := argon2HashRegexp.FindStringSubmatchIndex(hash)
if submatch == nil {
return nil, errors.New("crypto: incorrect argon2 hash format")
}
alg := string(argon2HashRegexp.ExpandString(nil, "$alg", hash, submatch))
v := string(argon2HashRegexp.ExpandString(nil, "$v", hash, submatch))
m := string(argon2HashRegexp.ExpandString(nil, "$m", hash, submatch))
t := string(argon2HashRegexp.ExpandString(nil, "$t", hash, submatch))
p := string(argon2HashRegexp.ExpandString(nil, "$p", hash, submatch))
keyid := string(argon2HashRegexp.ExpandString(nil, "$keyid", hash, submatch))
data := string(argon2HashRegexp.ExpandString(nil, "$data", hash, submatch))
saltB64 := string(argon2HashRegexp.ExpandString(nil, "$salt", hash, submatch))
hashB64 := string(argon2HashRegexp.ExpandString(nil, "$hash", hash, submatch))
if alg != "argon2i" && alg != "argon2id" {
return nil, fmt.Errorf("crypto: argon2 hash uses unsupported algorithm %q only argon2i and argon2id supported", alg)
}
if v != "19" {
return nil, fmt.Errorf("crypto: argon2 hash uses unsupported version %q only %d is supported", v, argon2.Version)
}
if data != "" {
return nil, fmt.Errorf("crypto: argon2 hashes with the data parameter not supported")
}
if keyid != "" {
return nil, fmt.Errorf("crypto: argon2 hashes with the keyid parameter not supported")
}
memory, err := strconv.ParseUint(m, 10, 32)
if err != nil {
return nil, fmt.Errorf("crypto: argon2 hash has invalid m parameter %q %w", m, err)
}
time, err := strconv.ParseUint(t, 10, 32)
if err != nil {
return nil, fmt.Errorf("crypto: argon2 hash has invalid t parameter %q %w", t, err)
}
threads, err := strconv.ParseUint(p, 10, 8)
if err != nil {
return nil, fmt.Errorf("crypto: argon2 hash has invalid p parameter %q %w", p, err)
}
rawHash, err := base64.RawStdEncoding.DecodeString(hashB64)
if err != nil {
return nil, fmt.Errorf("crypto: argon2 hash has invalid base64 in the hash section %w", err)
}
if len(rawHash) == 0 {
return nil, errors.New("crypto: argon2 hash is empty")
}
salt, err := base64.RawStdEncoding.DecodeString(saltB64)
if err != nil {
return nil, fmt.Errorf("crypto: argon2 hash has invalid base64 in the salt section %w", err)
}
if len(salt) == 0 {
return nil, errors.New("crypto: argon2 salt is empty")
}
input := Argon2HashInput{
alg: alg,
v: v,
memory: memory,
time: time,
threads: threads,
keyid: keyid,
data: data,
salt: salt,
rawHash: rawHash,
}
return &input, nil
}
func compareHashAndPasswordArgon2(ctx context.Context, hash, password string) error {
input, err := ParseArgon2Hash(hash)
if err != nil {
return err
}
attributes := []attribute.KeyValue{
attribute.String("alg", input.alg),
attribute.String("v", input.v),
attribute.Int64("m", int64(input.memory)),
attribute.Int64("t", int64(input.time)),
attribute.Int("p", int(input.threads)),
attribute.Int("len", len(input.rawHash)),
} // #nosec G115
var match bool
var derivedKey []byte
compareHashAndPasswordSubmittedCounter.Add(ctx, 1, metric.WithAttributes(attributes...))
defer func() {
attributes = append(attributes, attribute.Bool(
"match",
match,
))
compareHashAndPasswordCompletedCounter.Add(ctx, 1, metric.WithAttributes(attributes...))
}()
switch input.alg {
case "argon2i":
derivedKey = argon2.Key([]byte(password), input.salt, uint32(input.time), uint32(input.memory), uint8(input.threads), uint32(len(input.rawHash))) // #nosec G115
case "argon2id":
derivedKey = argon2.IDKey([]byte(password), input.salt, uint32(input.time), uint32(input.memory), uint8(input.threads), uint32(len(input.rawHash))) // #nosec G115
}
match = subtle.ConstantTimeCompare(derivedKey, input.rawHash) == 1
if !match {
return ErrArgon2MismatchedHashAndPassword
}
return nil
}
func compareHashAndPasswordFirebaseScrypt(ctx context.Context, hash, password string) error {
input, err := ParseFirebaseScryptHash(hash)
if err != nil {
return err
}
attributes := []attribute.KeyValue{
attribute.String("v", input.v),
attribute.Int64("n", int64(input.memory)),
attribute.Int64("r", int64(input.rounds)),
attribute.Int("p", int(input.threads)),
attribute.Int("len", len(input.rawHash)),
} // #nosec G115
var match bool
compareHashAndPasswordSubmittedCounter.Add(ctx, 1, metric.WithAttributes(attributes...))
defer func() {
attributes = append(attributes, attribute.Bool("match", match))
compareHashAndPasswordCompletedCounter.Add(ctx, 1, metric.WithAttributes(attributes...))
}()
derivedKey := firebaseScrypt([]byte(password), input.salt, input.signerKey, input.saltSeparator, input.memory, input.rounds, input.threads)
match = subtle.ConstantTimeCompare(derivedKey, input.rawHash) == 1
if !match {
return ErrScryptMismatchedHashAndPassword
}
return nil
}
func firebaseScrypt(password, salt, signerKey, saltSeparator []byte, memCost, rounds, p uint64) []byte {
ck := must(scrypt.Key(password, append(salt, saltSeparator...), int(memCost), int(rounds), int(p), FirebaseScryptKeyLen)) // #nosec G115
block := must(aes.NewCipher(ck))
cipherText := make([]byte, aes.BlockSize+len(signerKey))
// #nosec G407 -- Firebase scrypt requires deterministic IV for consistent results. See: JaakkoL/firebase-scrypt-python@master/firebasescrypt/firebasescrypt.py#L58
stream := cipher.NewCTR(block, cipherText[:aes.BlockSize])
stream.XORKeyStream(cipherText[aes.BlockSize:], signerKey)
return cipherText[aes.BlockSize:]
}
// CompareHashAndPassword compares the hash and
// password, returns nil if equal otherwise an error. Context can be used to
// cancel the hashing if the algorithm supports it.
func CompareHashAndPassword(ctx context.Context, hash, password string) error {
if strings.HasPrefix(hash, Argon2Prefix) {
return compareHashAndPasswordArgon2(ctx, hash, password)
} else if strings.HasPrefix(hash, FirebaseScryptPrefix) {
return compareHashAndPasswordFirebaseScrypt(ctx, hash, password)
}
// assume bcrypt
hashCost, err := bcrypt.Cost([]byte(hash))
if err != nil {
return err
}
attributes := []attribute.KeyValue{
attribute.String("alg", "bcrypt"),
attribute.Int("bcrypt_cost", hashCost),
}
compareHashAndPasswordSubmittedCounter.Add(ctx, 1, metric.WithAttributes(attributes...))
defer func() {
attributes = append(attributes, attribute.Bool(
"match",
!errors.Is(err, bcrypt.ErrMismatchedHashAndPassword),
))
compareHashAndPasswordCompletedCounter.Add(ctx, 1, metric.WithAttributes(attributes...))
}()
err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err
}
// GenerateFromPassword generates a password hash from a
// password, using PasswordHashCost. Context can be used to cancel the hashing
// if the algorithm supports it.
func GenerateFromPassword(ctx context.Context, password string) (string, error) {
hashCost := bcrypt.DefaultCost
switch PasswordHashCost {
case QuickHashCost:
hashCost = bcrypt.MinCost
}
attributes := []attribute.KeyValue{
attribute.String("alg", "bcrypt"),
attribute.Int("bcrypt_cost", hashCost),
}
generateFromPasswordSubmittedCounter.Add(ctx, 1, metric.WithAttributes(attributes...))
defer generateFromPasswordCompletedCounter.Add(ctx, 1, metric.WithAttributes(attributes...))
hash := must(bcrypt.GenerateFromPassword([]byte(password), hashCost))
return string(hash), nil
}
func GeneratePassword(requiredChars []string, length int) string {
passwordBuilder := strings.Builder{}
passwordBuilder.Grow(length)
// Add required characters
for _, group := range requiredChars {
if len(group) > 0 {
randomIndex := secureRandomInt(len(group))
passwordBuilder.WriteByte(group[randomIndex])
}
}
// Define a default character set for random generation (if needed)
const allChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// Fill the rest of the password
for passwordBuilder.Len() < length {
randomIndex := secureRandomInt(len(allChars))
passwordBuilder.WriteByte(allChars[randomIndex])
}
// Convert to byte slice for shuffling
passwordBytes := []byte(passwordBuilder.String())
// Secure shuffling
for i := len(passwordBytes) - 1; i > 0; i-- {
j := secureRandomInt(i + 1)
passwordBytes[i], passwordBytes[j] = passwordBytes[j], passwordBytes[i]
}
return string(passwordBytes)
}