419 lines
14 KiB
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)
|
|
}
|