Skip to content

Commit

Permalink
feat: add support for migration of firebase scrypt passwords (#1768)
Browse files Browse the repository at this point in the history
## What kind of change does this PR introduce?

Fix #1750. Firebase uses a [modified version of
scrypt](https://github.com/firebase/scrypt) We add support for Firebase
Scrypt hashes so that developers can move over from Firebase (or
similar) without the obligation to force a password reset for all users.

As there is no pre-defined convention for Firebase scrypt hashes, we
establish the following:

```
$fbscrypt$v=1,n=<N>,r=<r>,p=<p>[,ss=<salt_separator>][,sk=<signer_key>]$<salt>$<hash>
```

```
$fbscrypt: Firebase scrypt Identifier
$v: version identifier. Intended to allow for flexibility in parameters used.
$n: N is the CPU/memory cost parameter.
$r: block size
$p: parallelization
$ss: salt seperator, optional, only if using firebase,  base64-encoded string used to separate the salt from other parameters.
$sk: signer key, a base64-encoded string used as an additional input to the hash function.
$<salt>: base64 encoded salt
$<hash>: base64 encoded output
````

Developers can extract their [hash parameters from the firebase
console](https://firebaseopensource.com/projects/firebase/scrypt/)

For testing and debugging, clone this
[utility](https://github.com/firebase/scrypt/#finding-the-password-hash-parameters)
and follow the instructions in `BUILDING`.

On MacOS please add the following flags when attempting to build so as
to guard against error: `AES_FUNCTION` missing

```
export CFLAGS="-I$(brew --prefix openssl)/include"
export LDFLAGS="-L$(brew --prefix openssl)/lib -L/usr/local/opt/openssl/lib"
```

[More details about export from
CLI](https://firebase.google.com/docs/cli/auth)
  • Loading branch information
J0 authored Sep 26, 2024
1 parent 7a5411f commit ba00f75
Show file tree
Hide file tree
Showing 4 changed files with 206 additions and 3 deletions.
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) {
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" {
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

0 comments on commit ba00f75

Please sign in to comment.