diff --git a/internal/crypto/password.go b/internal/crypto/password.go index 055b611bc..245de75b8 100644 --- a/internal/crypto/password.go +++ b/internal/crypto/password.go @@ -2,6 +2,8 @@ package crypto import ( "context" + "crypto/aes" + "crypto/cipher" "crypto/subtle" "encoding/base64" "errors" @@ -16,6 +18,7 @@ import ( "golang.org/x/crypto/argon2" "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/scrypt" ) type HashCost = int @@ -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 @@ -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("^[$](?Pargon2(d|i|id))[$]v=(?P(16|19))[$]m=(?P[0-9]+),t=(?P[0-9]+),p=(?P

[0-9]+)(,keyid=(?P[^,]+))?(,data=(?P[^$]+))?[$](?P[^$]+)[$](?P.+)$") +var scryptHashRegexp = regexp.MustCompile(`^\$(?Pfbscrypt)\$v=(?P[0-9]+),n=(?P[0-9]+),r=(?P[0-9]+),p=(?P

[0-9]+)(?:,ss=(?P[^,]+))?(?:,sk=(?P[^$]+))?\$(?P[^$]+)\$(?P.+)$`) type Argon2HashInput struct { alg string @@ -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") } @@ -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 diff --git a/internal/crypto/password_test.go b/internal/crypto/password_test.go index 59eb0a08b..45ed03d04 100644 --- a/internal/crypto/password_test.go +++ b/internal/crypto/password_test.go @@ -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") + } + }) + } +} diff --git a/internal/models/user.go b/internal/models/user.go index ffbd241c5..520d31fe9 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -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)) @@ -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 { diff --git a/internal/models/user_test.go b/internal/models/user_test.go index 92b0858ce..034954389 100644 --- a/internal/models/user_test.go +++ b/internal/models/user_test.go @@ -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 { @@ -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 {