Skip to content

Commit

Permalink
feat: add support for scrypt hashes
Browse files Browse the repository at this point in the history
  • Loading branch information
J0 committed Sep 25, 2024
1 parent 07ed63d commit 242db78
Show file tree
Hide file tree
Showing 3 changed files with 67 additions and 49 deletions.
97 changes: 50 additions & 47 deletions internal/crypto/password.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ const (
// useful for tests only.
QuickHashCost HashCost = iota

Argon2Prefix = "$argon2"
ScryptPrefix = "$scrypt"
Argon2Prefix = "$argon2"
FirebaseScryptPrefix = "$fbscrypt"
)

// PasswordHashCost is the current pasword hashing cost
Expand All @@ -57,7 +57,18 @@ var ErrScryptMismatchedHashAndPassword = errors.New("crypto: scrypt hash and pas

// 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(`^\$scrypt\$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>.+)$`)
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>.+)$`)

func flexibleBase64Decode(data string) ([]byte, error) {
// Try StdEncoding first
decoded, err := base64.StdEncoding.DecodeString(data)
if err == nil {
return decoded, nil
}

// If StdEncoding fails, try RawStdEncoding
return base64.RawStdEncoding.DecodeString(data)
}

type Argon2HashInput struct {
alg string
Expand All @@ -71,25 +82,25 @@ type Argon2HashInput struct {
rawHash []byte
}

type ScryptHashInput struct {
type FirebaseScryptHashInput struct {
alg string
v string
memory uint64
rounds uint64
threads uint64
saltSeparator []byte // Optional: Salt separator used in Firebase-style scrypt
signerKey []byte // Optional: Signer key used in Firebase-style scrypt
saltSeparator []byte
signerKey []byte
salt []byte
rawHash []byte
decodedHash []byte
}

func ParseScryptHash(hash string) (*ScryptHashInput, error) {
func ParseFirebaseScryptHash(hash string) (*FirebaseScryptHashInput, error) {
submatch := scryptHashRegexp.FindStringSubmatchIndex(hash)
if submatch == nil {
return nil, errors.New("crypto: incorrect scrypt hash format")
}

alg := string(argon2HashRegexp.ExpandString(nil, "$alg", hash, submatch))
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))
Expand All @@ -99,68 +110,68 @@ func ParseScryptHash(hash string) (*ScryptHashInput, error) {
saltB64 := string(scryptHashRegexp.ExpandString(nil, "$salt", hash, submatch))
hashB64 := string(scryptHashRegexp.ExpandString(nil, "$hash", hash, submatch))

if alg != "scrypt" {
return nil, fmt.Errorf("crypto: scrypt hash uses unsupported algorithm %q only scrypt supported", alg)
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: scrypt hash uses unsupported version $q only version 1 is supported", v)
return nil, fmt.Errorf("crypto: Firebase scrypt hash uses unsupported version %q only version 1 is supported", v)
}

memory, err := strconv.ParseUint(n, 10, 32)
if err != nil {
return nil, fmt.Errorf("crypto: scrypt hash has invalid n parameter %q %w", memory, err)
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid n parameter %q %w", memory, err)
}

if memory <= 1 || (memory&(memory-1)) != 0 {
return nil, fmt.Errorf("crypto: scrypt hash has invalid n parameter %q: must be a power of 2 greater than 1", n)
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid n parameter %q: must be a power of 2 greater than 1", n)
}

rounds, err := strconv.ParseUint(r, 10, 64)
if err != nil {
return nil, fmt.Errorf("crypto: scrypt hash has invalid r parameter %q: %w", r, err)
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: argon2 hash has invalid p parameter %q %w", p, err)
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid p parameter %q %w", p, err)
}

if rounds*threads >= 1<<30 {
return nil, fmt.Errorf("crypto: scrypt hash has invalid r and p parameters: r * p must be < 2^30")
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid r and p parameters: r * p must be < 2^30")
}

salt, err := base64.RawStdEncoding.DecodeString(saltB64)
salt, err := flexibleBase64Decode(saltB64)
if err != nil {
return nil, fmt.Errorf("crypto: scrypt hash has invalid base64 in the salt section: %w", err)
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid base64 in the salt section: %w", err)
}

rawHash, err := base64.RawStdEncoding.DecodeString(hashB64)
decodedHash, err := flexibleBase64Decode(hashB64)
if err != nil {
return nil, fmt.Errorf("crypto: scrypt hash has invalid base64 in the hash section: %w", err)
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid base64 in the hash section: %w", err)
}

var saltSeparator, signerKey []byte
if ss != "" {
saltSeparator, err = base64.RawStdEncoding.DecodeString(ss)
saltSeparator, err = flexibleBase64Decode(ss)
if err != nil {
return nil, fmt.Errorf("crypto: scrypt hash has invalid base64 in the salt separator section: %w", err)
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid base64 in the salt separator section: %w", err)
}
}
if sk != "" {
signerKey, err = base64.RawStdEncoding.DecodeString(sk)
signerKey, err = flexibleBase64Decode(sk)
if err != nil {
return nil, fmt.Errorf("crypto: scrypt hash has invalid base64 in the signer key section: %w", err)
return nil, fmt.Errorf("crypto: Firebase scrypt hash has invalid base64 in the signer key section: %w", err)
}
}

input := &ScryptHashInput{
input := &FirebaseScryptHashInput{
alg: alg,
v: v,
memory: memory,
rounds: rounds,
threads: threads,
salt: salt,
rawHash: rawHash,
decodedHash: decodedHash,
saltSeparator: saltSeparator,
signerKey: signerKey,
}
Expand Down Expand Up @@ -274,8 +285,8 @@ func compareHashAndPasswordArgon2(ctx context.Context, hash, password string) er
return nil
}

func compareHashAndPasswordScrypt(ctx context.Context, hash, password string) error {
input, err := ParseScryptHash(hash)
func compareHashAndPasswordFirebaseScrypt(ctx context.Context, hash, password string) error {
input, err := ParseFirebaseScryptHash(hash)
if err != nil {
return err
}
Expand All @@ -286,8 +297,7 @@ func compareHashAndPasswordScrypt(ctx context.Context, hash, password string) er
attribute.Int64("n", int64(input.memory)),
attribute.Int64("r", int64(input.rounds)),
attribute.Int("p", int(input.threads)),
attribute.Int("len", len(input.rawHash)),
attribute.Bool("is_firebase", len(input.saltSeparator) > 0),
attribute.Int("len", len(input.decodedHash)),
}

var match bool
Expand All @@ -299,31 +309,24 @@ func compareHashAndPasswordScrypt(ctx context.Context, hash, password string) er
}()

switch input.alg {
case "scrypt":
if len(input.saltSeparator) > 0 {
// Firebase-style scrypt
combinedSalt := append(input.salt, input.saltSeparator...)
derivedKey, err = firebaseScrypt([]byte(password), combinedSalt, input.signerKey, input.memory, input.rounds, input.threads, len(input.rawHash))
} else {
// Standard scrypt
derivedKey, err = scrypt.Key([]byte(password), input.salt, int(input.memory), int(input.rounds), int(input.threads), len(input.rawHash))
}
if err != nil {
return fmt.Errorf("failed to derive scrypt key: %w", err)
}
case "fbscrypt":
// Firebase-style scrypt
combinedSalt := append(input.salt, input.saltSeparator...)
// TODO: move into constant above
derivedKey, err = firebaseScrypt([]byte(password), combinedSalt, input.signerKey, input.memory, input.rounds, input.threads, len(input.decodedHash))

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

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

func firebaseScrypt(password, salt, signerKey []byte, N, r, p uint64, keyLen int) ([]byte, error) {

ck, err := scrypt.Key(password, salt, 1<<N, int(r), int(p), keyLen)
if err != nil {
return nil, err
Expand All @@ -346,8 +349,8 @@ func firebaseScrypt(password, salt, signerKey []byte, N, r, p uint64, keyLen int
func CompareHashAndPassword(ctx context.Context, hash, password string) error {
if strings.HasPrefix(hash, Argon2Prefix) {
return compareHashAndPasswordArgon2(ctx, hash, password)
} else if strings.HasPrefix(hash, ScryptPrefix) {
return compareHashAndPasswordScrypt(ctx, hash, password)
} else if strings.HasPrefix(hash, FirebaseScryptPrefix) {
return compareHashAndPasswordFirebaseScrypt(ctx, hash, password)
}

// assume bcrypt
Expand Down
15 changes: 15 additions & 0 deletions internal/crypto/password_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ func TestGeneratePassword(t *testing.T) {
length: 8,
wantErr: false,


func TestScrypt(t *testing.T) {
testCases := []scryptTestCase{
{
name: "Firebase Scrypt: appropriate hash",

hash: "$fbscrypt$v=1,n=16,r=8,p=1,ss=Bw==,sk=ou9tdYTGyYm8kuR6Dt0Bp0kDuAYoXrK16mbZO4yGwAn3oLspjnN0/c41v8xZnO1n14J3MjKj1b2g6AUCAlFwMw==$qV3N5ZhVttJ3YQ==$AwQW7J1yTqKV6neJmb1GbN9zTyNGfhotrOkPS+mtxarhNcLaB4ha49lJxVMsdV3BQx0G4XD0rOe3KaZN0dSyNg==",
password: "mytestpassword",
shouldPass: true,
},
{
name: "Firebase Scrypt: incorrect hash",
hash: "$fbscrypt$v=1,n=16,r=8,p=1,ss=Bw==,sk=ou9tdYTGyYm8kuR6Dt0Bp0kDuAYoXrK16mbZO4yGwAn3oLspjnN0/c41v8xZnO1n14J3MjKj1b2g6AUCAlFwMw==$qV3N5ZhVttJ3YQ==$differenthash",
password: "mytestpassword",
shouldPass: false,
},
}
if (err != nil) != tt.wantErr {
Expand Down
4 changes: 2 additions & 2 deletions internal/models/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,8 @@ func (ts *UserTestSuite) TestNewUserWithPasswordHashSuccess() {
hash: "$argon2id$v=19$m=32,t=3,p=2$SFVpOWJ0eXhjRzVkdGN1RQ$RXnb8rh7LaDcn07xsssqqulZYXOM/EUCEFMVcAcyYVk",
},
{
desc: "Valid scrypt hash",
hash: "$scrypt$ln=16384,r=8,p=1$c2FsdHlzYWx0$hash_output_base64",
desc: "Valid Firebase scrypt hash",
hash: "$scrypt$v=1$n=14,r=8,p=1,ss=Bw==,sk=ou9tdYTGyYm8kuR6Dt0Bp0kDuAYoXrK16mbZO4yGwAn3oLspjnN0/c41v8xZnO1n14J3MjKj1b2g6AUCAlFwMw==$qV3N5ZhVttJ3YQ==$AwQW7J1yTqKV6neJmb1GbN9zTyNGfhotrOkPS+mtxarhNcLaB4ha49lJxVMsdV3BQx0G4XD0rOe3KaZN0dSyNg==",
},
}

Expand Down

0 comments on commit 242db78

Please sign in to comment.