Skip to content

Commit 236219e

Browse files
J0xeladotbe
authored andcommitted
feat: add support for migration of firebase scrypt passwords (supabase#1768)
## What kind of change does this PR introduce? Fix supabase#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)
1 parent 6345a7c commit 236219e

File tree

4 files changed

+206
-3
lines changed

4 files changed

+206
-3
lines changed

internal/crypto/password.go

Lines changed: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package crypto
22

33
import (
44
"context"
5+
"crypto/aes"
6+
"crypto/cipher"
57
"crypto/subtle"
68
"encoding/base64"
79
"errors"
@@ -16,6 +18,7 @@ import (
1618

1719
"golang.org/x/crypto/argon2"
1820
"golang.org/x/crypto/bcrypt"
21+
"golang.org/x/crypto/scrypt"
1922
)
2023

2124
type HashCost = int
@@ -30,7 +33,9 @@ const (
3033
// useful for tests only.
3134
QuickHashCost HashCost = iota
3235

33-
Argon2Prefix = "$argon2"
36+
Argon2Prefix = "$argon2"
37+
FirebaseScryptPrefix = "$fbscrypt"
38+
FirebaseScryptKeyLen = 32 // Firebase uses AES-256 which requires 32 byte keys: https://pkg.go.dev/golang.org/x/crypto/scrypt#Key
3439
)
3540

3641
// PasswordHashCost is the current pasword hashing cost
@@ -49,9 +54,11 @@ var (
4954
)
5055

5156
var ErrArgon2MismatchedHashAndPassword = errors.New("crypto: argon2 hash and password mismatch")
57+
var ErrScryptMismatchedHashAndPassword = errors.New("crypto: fbscrypt hash and password mismatch")
5258

5359
// argon2HashRegexp https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md#argon2-encoding
5460
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>.+)$")
61+
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>.+)$`)
5562

5663
type Argon2HashInput struct {
5764
alg string
@@ -65,9 +72,95 @@ type Argon2HashInput struct {
6572
rawHash []byte
6673
}
6774

75+
type FirebaseScryptHashInput struct {
76+
alg string
77+
v string
78+
memory uint64
79+
rounds uint64
80+
threads uint64
81+
saltSeparator []byte
82+
signerKey []byte
83+
salt []byte
84+
rawHash []byte
85+
}
86+
87+
// See: https://github.com/firebase/scrypt for implementation
88+
func ParseFirebaseScryptHash(hash string) (*FirebaseScryptHashInput, error) {
89+
submatch := scryptHashRegexp.FindStringSubmatchIndex(hash)
90+
if submatch == nil {
91+
return nil, errors.New("crypto: incorrect scrypt hash format")
92+
}
93+
94+
alg := string(scryptHashRegexp.ExpandString(nil, "$alg", hash, submatch))
95+
v := string(scryptHashRegexp.ExpandString(nil, "$v", hash, submatch))
96+
n := string(scryptHashRegexp.ExpandString(nil, "$n", hash, submatch))
97+
r := string(scryptHashRegexp.ExpandString(nil, "$r", hash, submatch))
98+
p := string(scryptHashRegexp.ExpandString(nil, "$p", hash, submatch))
99+
ss := string(scryptHashRegexp.ExpandString(nil, "$ss", hash, submatch))
100+
sk := string(scryptHashRegexp.ExpandString(nil, "$sk", hash, submatch))
101+
saltB64 := string(scryptHashRegexp.ExpandString(nil, "$salt", hash, submatch))
102+
hashB64 := string(scryptHashRegexp.ExpandString(nil, "$hash", hash, submatch))
103+
104+
if alg != "fbscrypt" {
105+
return nil, fmt.Errorf("crypto: Firebase scrypt hash uses unsupported algorithm %q only fbscrypt supported", alg)
106+
}
107+
if v != "1" {
108+
return nil, fmt.Errorf("crypto: Firebase scrypt hash uses unsupported version %q only version 1 is supported", v)
109+
}
110+
memoryPower, err := strconv.ParseUint(n, 10, 32)
111+
if err != nil {
112+
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid n parameter %q %w", n, err)
113+
}
114+
if memoryPower == 0 {
115+
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid n parameter %q: must be greater than 0", n)
116+
}
117+
// Exponent is passed in
118+
memory := uint64(1) << memoryPower
119+
rounds, err := strconv.ParseUint(r, 10, 64)
120+
if err != nil {
121+
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid r parameter %q: %w", r, err)
122+
}
123+
124+
threads, err := strconv.ParseUint(p, 10, 8)
125+
if err != nil {
126+
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid p parameter %q %w", p, err)
127+
}
128+
129+
rawHash, err := base64.StdEncoding.DecodeString(hashB64)
130+
if err != nil {
131+
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid base64 in the hash section %w", err)
132+
}
133+
134+
salt, err := base64.StdEncoding.DecodeString(saltB64)
135+
if err != nil {
136+
return nil, fmt.Errorf("crypto: Firebase scrypt salt has invalid base64 in the hash section %w", err)
137+
}
138+
139+
var saltSeparator, signerKey []byte
140+
if signerKey, err = base64.StdEncoding.DecodeString(sk); err != nil {
141+
return nil, err
142+
}
143+
if saltSeparator, err = base64.StdEncoding.DecodeString(ss); err != nil {
144+
return nil, err
145+
}
146+
147+
input := &FirebaseScryptHashInput{
148+
alg: alg,
149+
v: v,
150+
memory: memory,
151+
rounds: rounds,
152+
threads: threads,
153+
salt: salt,
154+
rawHash: rawHash,
155+
saltSeparator: saltSeparator,
156+
signerKey: signerKey,
157+
}
158+
159+
return input, nil
160+
}
161+
68162
func ParseArgon2Hash(hash string) (*Argon2HashInput, error) {
69163
submatch := argon2HashRegexp.FindStringSubmatchIndex(hash)
70-
71164
if submatch == nil {
72165
return nil, errors.New("crypto: incorrect argon2 hash format")
73166
}
@@ -172,12 +265,74 @@ func compareHashAndPasswordArgon2(ctx context.Context, hash, password string) er
172265
return nil
173266
}
174267

268+
func compareHashAndPasswordFirebaseScrypt(ctx context.Context, hash, password string) error {
269+
input, err := ParseFirebaseScryptHash(hash)
270+
if err != nil {
271+
return err
272+
}
273+
274+
attributes := []attribute.KeyValue{
275+
attribute.String("alg", input.alg),
276+
attribute.String("v", input.v),
277+
attribute.Int64("n", int64(input.memory)),
278+
attribute.Int64("r", int64(input.rounds)),
279+
attribute.Int("p", int(input.threads)),
280+
attribute.Int("len", len(input.rawHash)),
281+
} // #nosec G115
282+
283+
var match bool
284+
var derivedKey []byte
285+
compareHashAndPasswordSubmittedCounter.Add(ctx, 1, metric.WithAttributes(attributes...))
286+
defer func() {
287+
attributes = append(attributes, attribute.Bool("match", match))
288+
compareHashAndPasswordCompletedCounter.Add(ctx, 1, metric.WithAttributes(attributes...))
289+
}()
290+
291+
switch input.alg {
292+
case "fbscrypt":
293+
derivedKey, err = firebaseScrypt([]byte(password), input.salt, input.signerKey, input.saltSeparator, input.memory, input.rounds, input.threads, FirebaseScryptKeyLen)
294+
if err != nil {
295+
return err
296+
}
297+
298+
match = subtle.ConstantTimeCompare(derivedKey, input.rawHash) == 1
299+
if !match {
300+
return ErrScryptMismatchedHashAndPassword
301+
}
302+
303+
default:
304+
return fmt.Errorf("unsupported algorithm: %s", input.alg)
305+
}
306+
307+
return nil
308+
}
309+
310+
func firebaseScrypt(password, salt, signerKey, saltSeparator []byte, memCost, rounds, p, keyLen uint64) ([]byte, error) {
311+
ck, err := scrypt.Key(password, append(salt, saltSeparator...), int(memCost), int(rounds), int(p), int(keyLen)) // #nosec G115
312+
if err != nil {
313+
return nil, err
314+
}
315+
316+
var block cipher.Block
317+
if block, err = aes.NewCipher(ck); err != nil {
318+
return nil, err
319+
}
320+
321+
cipherText := make([]byte, aes.BlockSize+len(signerKey))
322+
// #nosec G407 -- Firebase scrypt requires deterministic IV for consistent results. See: JaakkoL/firebase-scrypt-python@master/firebasescrypt/firebasescrypt.py#L58
323+
stream := cipher.NewCTR(block, cipherText[:aes.BlockSize])
324+
stream.XORKeyStream(cipherText[aes.BlockSize:], signerKey)
325+
return cipherText[aes.BlockSize:], nil
326+
}
327+
175328
// CompareHashAndPassword compares the hash and
176329
// password, returns nil if equal otherwise an error. Context can be used to
177330
// cancel the hashing if the algorithm supports it.
178331
func CompareHashAndPassword(ctx context.Context, hash, password string) error {
179332
if strings.HasPrefix(hash, Argon2Prefix) {
180333
return compareHashAndPasswordArgon2(ctx, hash, password)
334+
} else if strings.HasPrefix(hash, FirebaseScryptPrefix) {
335+
return compareHashAndPasswordFirebaseScrypt(ctx, hash, password)
181336
}
182337

183338
// assume bcrypt

internal/crypto/password_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,38 @@ func TestGeneratePassword(t *testing.T) {
8484
passwords[p] = true
8585
}
8686
}
87+
88+
type scryptTestCase struct {
89+
name string
90+
hash string
91+
password string
92+
shouldPass bool
93+
}
94+
95+
func TestScrypt(t *testing.T) {
96+
testCases := []scryptTestCase{
97+
{
98+
name: "Firebase Scrypt: appropriate hash",
99+
hash: "$fbscrypt$v=1,n=14,r=8,p=1,ss=Bw==,sk=ou9tdYTGyYm8kuR6Dt0Bp0kDuAYoXrK16mbZO4yGwAn3oLspjnN0/c41v8xZnO1n14J3MjKj1b2g6AUCAlFwMw==$C0sHCg9ek77hsg==$zKVTMvnWVw5BBOZNUdnsalx4c4c7y/w7IS5p6Ut2+CfEFFlz37J9huyQfov4iizN8dbjvEJlM5tQaJP84+hfTw==",
100+
password: "mytestpassword",
101+
shouldPass: true,
102+
},
103+
{
104+
name: "Firebase Scrypt: incorrect hash",
105+
hash: "$fbscrypt$v=1,n=14,r=8,p=1,ss=Bw==,sk=ou9tdYTGyYm8kuR6Dt0Bp0kDuAYoXrK16mbZO4yGwAn3oLspjnN0/c41v8xZnO1n14J3MjKj1b2g6AUCAlFwMw==$C0sHCg9ek77hsg==$ZGlmZmVyZW50aGFzaA==",
106+
password: "mytestpassword",
107+
shouldPass: false,
108+
},
109+
}
110+
111+
for _, tc := range testCases {
112+
t.Run(tc.name, func(t *testing.T) {
113+
err := CompareHashAndPassword(context.Background(), tc.hash, tc.password)
114+
if tc.shouldPass {
115+
assert.NoError(t, err, "Expected test case to pass, but it failed")
116+
} else {
117+
assert.Error(t, err, "Expected test case to fail, but it passed")
118+
}
119+
})
120+
}
121+
}

internal/models/user.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ func NewUserWithPasswordHash(phone, email, passwordHash, aud string, userData ma
7878
if err != nil {
7979
return nil, err
8080
}
81+
} else if strings.HasPrefix(passwordHash, crypto.FirebaseScryptPrefix) {
82+
_, err := crypto.ParseFirebaseScryptHash(passwordHash)
83+
if err != nil {
84+
return nil, err
85+
}
8186
} else {
8287
// verify that the hash is a bcrypt hash
8388
_, err := bcrypt.Cost([]byte(passwordHash))
@@ -400,7 +405,7 @@ func (u *User) Authenticate(ctx context.Context, tx *storage.Connection, passwor
400405

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

403-
if !strings.HasPrefix(hash, crypto.Argon2Prefix) {
408+
if !strings.HasPrefix(hash, crypto.Argon2Prefix) && !strings.HasPrefix(hash, crypto.FirebaseScryptPrefix) {
404409
// check if cost exceeds default cost or is too low
405410
cost, err := bcrypt.Cost([]byte(hash))
406411
if err != nil {

internal/models/user_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,10 @@ func (ts *UserTestSuite) TestNewUserWithPasswordHashSuccess() {
385385
desc: "Valid argon2id hash",
386386
hash: "$argon2id$v=19$m=32,t=3,p=2$SFVpOWJ0eXhjRzVkdGN1RQ$RXnb8rh7LaDcn07xsssqqulZYXOM/EUCEFMVcAcyYVk",
387387
},
388+
{
389+
desc: "Valid Firebase scrypt hash",
390+
hash: "$fbscrypt$v=1,n=14,r=8,p=1,ss=Bw==,sk=ou9tdYTGyYm8kuR6Dt0Bp0kDuAYoXrK16mbZO4yGwAn3oLspjnN0/c41v8xZnO1n14J3MjKj1b2g6AUCAlFwMw==$C0sHCg9ek77hsg==$ZGlmZmVyZW50aGFzaA==",
391+
},
388392
}
389393

390394
for _, c := range cases {
@@ -409,6 +413,10 @@ func (ts *UserTestSuite) TestNewUserWithPasswordHashFailure() {
409413
desc: "Invalid bcrypt hash",
410414
hash: "plaintest_password",
411415
},
416+
{
417+
desc: "Invalid scrypt hash",
418+
hash: "$fbscrypt$invalid",
419+
},
412420
}
413421

414422
for _, c := range cases {

0 commit comments

Comments
 (0)