Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for migration of firebase scrypt passwords #1768

Merged
merged 1 commit into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 157 additions & 2 deletions internal/crypto/password.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package crypto

import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/subtle"
"encoding/base64"
"errors"
Expand All @@ -16,6 +18,7 @@ import (

"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/scrypt"
)

type HashCost = int
Expand All @@ -30,7 +33,9 @@ const (
// useful for tests only.
QuickHashCost HashCost = iota

Argon2Prefix = "$argon2"
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
Expand All @@ -49,9 +54,11 @@ var (
)

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 scryptHashRegexp = regexp.MustCompile(`^\$(?P<alg>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
Expand All @@ -65,9 +72,95 @@ type Argon2HashInput struct {
rawHash []byte
}

type FirebaseScryptHashInput struct {
alg string
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) {
J0 marked this conversation as resolved.
Show resolved Hide resolved
submatch := scryptHashRegexp.FindStringSubmatchIndex(hash)
if submatch == nil {
return nil, errors.New("crypto: incorrect scrypt hash format")
}

alg := string(scryptHashRegexp.ExpandString(nil, "$alg", hash, submatch))
v := string(scryptHashRegexp.ExpandString(nil, "$v", hash, submatch))
n := string(scryptHashRegexp.ExpandString(nil, "$n", hash, submatch))
r := string(scryptHashRegexp.ExpandString(nil, "$r", hash, submatch))
p := string(scryptHashRegexp.ExpandString(nil, "$p", hash, submatch))
ss := string(scryptHashRegexp.ExpandString(nil, "$ss", hash, submatch))
sk := string(scryptHashRegexp.ExpandString(nil, "$sk", hash, submatch))
saltB64 := string(scryptHashRegexp.ExpandString(nil, "$salt", hash, submatch))
hashB64 := string(scryptHashRegexp.ExpandString(nil, "$hash", hash, submatch))

if alg != "fbscrypt" {
return nil, fmt.Errorf("crypto: Firebase scrypt hash uses unsupported algorithm %q only fbscrypt supported", alg)
}
if v != "1" {
J0 marked this conversation as resolved.
Show resolved Hide resolved
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 parameter %q: must be greater than 0", n)
}
// Exponent is passed in
memory := uint64(1) << memoryPower
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)
}

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)
}

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{
alg: alg,
v: v,
memory: memory,
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")
}
Expand Down Expand Up @@ -172,12 +265,74 @@ func compareHashAndPasswordArgon2(ctx context.Context, hash, password string) er
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("alg", input.alg),
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
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 "fbscrypt":
derivedKey, err = firebaseScrypt([]byte(password), input.salt, input.signerKey, input.saltSeparator, input.memory, input.rounds, input.threads, FirebaseScryptKeyLen)
if err != nil {
return err
}

match = subtle.ConstantTimeCompare(derivedKey, input.rawHash) == 1
if !match {
return ErrScryptMismatchedHashAndPassword
}

default:
return fmt.Errorf("unsupported algorithm: %s", input.alg)
}

return nil
}

func firebaseScrypt(password, salt, signerKey, saltSeparator []byte, memCost, rounds, p, keyLen uint64) ([]byte, error) {
ck, err := scrypt.Key(password, append(salt, saltSeparator...), int(memCost), int(rounds), int(p), int(keyLen)) // #nosec G115
if err != nil {
return nil, err
}

var block cipher.Block
if block, err = aes.NewCipher(ck); err != nil {
return nil, err
}

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:], nil
}

// 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
Expand Down
35 changes: 35 additions & 0 deletions internal/crypto/password_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,38 @@ func TestGeneratePassword(t *testing.T) {
passwords[p] = true
}
}

type scryptTestCase struct {
name string
hash string
password string
shouldPass bool
}

func TestScrypt(t *testing.T) {
testCases := []scryptTestCase{
{
name: "Firebase Scrypt: appropriate hash",
hash: "$fbscrypt$v=1,n=14,r=8,p=1,ss=Bw==,sk=ou9tdYTGyYm8kuR6Dt0Bp0kDuAYoXrK16mbZO4yGwAn3oLspjnN0/c41v8xZnO1n14J3MjKj1b2g6AUCAlFwMw==$C0sHCg9ek77hsg==$zKVTMvnWVw5BBOZNUdnsalx4c4c7y/w7IS5p6Ut2+CfEFFlz37J9huyQfov4iizN8dbjvEJlM5tQaJP84+hfTw==",
password: "mytestpassword",
shouldPass: true,
},
{
name: "Firebase Scrypt: incorrect hash",
hash: "$fbscrypt$v=1,n=14,r=8,p=1,ss=Bw==,sk=ou9tdYTGyYm8kuR6Dt0Bp0kDuAYoXrK16mbZO4yGwAn3oLspjnN0/c41v8xZnO1n14J3MjKj1b2g6AUCAlFwMw==$C0sHCg9ek77hsg==$ZGlmZmVyZW50aGFzaA==",
password: "mytestpassword",
shouldPass: false,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := CompareHashAndPassword(context.Background(), tc.hash, tc.password)
if tc.shouldPass {
assert.NoError(t, err, "Expected test case to pass, but it failed")
} else {
assert.Error(t, err, "Expected test case to fail, but it passed")
}
})
}
}
7 changes: 6 additions & 1 deletion internal/models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ func NewUserWithPasswordHash(phone, email, passwordHash, aud string, userData ma
if err != nil {
return nil, err
}
} else if strings.HasPrefix(passwordHash, crypto.FirebaseScryptPrefix) {
_, err := crypto.ParseFirebaseScryptHash(passwordHash)
if err != nil {
return nil, err
}
} else {
// verify that the hash is a bcrypt hash
_, err := bcrypt.Cost([]byte(passwordHash))
Expand Down Expand Up @@ -400,7 +405,7 @@ func (u *User) Authenticate(ctx context.Context, tx *storage.Connection, passwor

compareErr := crypto.CompareHashAndPassword(ctx, hash, password)

if !strings.HasPrefix(hash, crypto.Argon2Prefix) {
if !strings.HasPrefix(hash, crypto.Argon2Prefix) && !strings.HasPrefix(hash, crypto.FirebaseScryptPrefix) {
// check if cost exceeds default cost or is too low
cost, err := bcrypt.Cost([]byte(hash))
if err != nil {
Expand Down
8 changes: 8 additions & 0 deletions internal/models/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,10 @@ func (ts *UserTestSuite) TestNewUserWithPasswordHashSuccess() {
desc: "Valid argon2id hash",
hash: "$argon2id$v=19$m=32,t=3,p=2$SFVpOWJ0eXhjRzVkdGN1RQ$RXnb8rh7LaDcn07xsssqqulZYXOM/EUCEFMVcAcyYVk",
},
{
desc: "Valid Firebase scrypt hash",
hash: "$fbscrypt$v=1,n=14,r=8,p=1,ss=Bw==,sk=ou9tdYTGyYm8kuR6Dt0Bp0kDuAYoXrK16mbZO4yGwAn3oLspjnN0/c41v8xZnO1n14J3MjKj1b2g6AUCAlFwMw==$C0sHCg9ek77hsg==$ZGlmZmVyZW50aGFzaA==",
},
}

for _, c := range cases {
Expand All @@ -409,6 +413,10 @@ func (ts *UserTestSuite) TestNewUserWithPasswordHashFailure() {
desc: "Invalid bcrypt hash",
hash: "plaintest_password",
},
{
desc: "Invalid scrypt hash",
hash: "$fbscrypt$invalid",
},
}

for _, c := range cases {
Expand Down
Loading