Secure password hashing in Go

SnykSec - Dec 7 '23 - - Dev Community

User credentials are the information required to authenticate a user's identity and grant them access to a system or application. Typically, this includes a username or email address and a password. While a username can be stored as plaintext in a database, sensitive information like email addresses or passwords should not.

If a malicious actor gains access to your database where you store this information, you don't want to hand over this information to them easily. Instead, you want to ensure that they are unable to access this information, even if they make a determined effort with all their available resources. This is where hashing becomes important.

Following up on our previous post about password hashing in Java, this post will cover password hashing in Go. Let's begin by revisiting some of the concepts and best practices of password hashing.

Use hashing for passwords

A hash is a mathematical function that takes an input (in this case, a password) and produces a fixed-size string of characters, which is typically a combination of letters and numbers. The output, known as the hash value or hash code, is unique to the input. Hash functions are designed to be one-way, meaning that it is computationally infeasible to reverse-engineer the original input from the hash value. In the context of secure password hashing, passwords are hashed and stored as hash values instead of plain text to enhance security.

The difference between hashing and encryption is that, while hashing is a one-way function, encryption is a two-way function that allows the retrieval of the original value from the encrypted value. Encryption is commonly used for sensitive data like email and other PII, but for passwords, hashing is the recommended approach.

But, if hashing is a one-way function, how do we verify user credentials? The process is simple: we create a hash of the user's plain text password that they provide and compare it with the hash we have stored in our database. If the hashes match, the password is correct. However, if the hashes don't match, the credentials are incorrect. The beauty of hashing is that we never need to know a user's actual password!

Attack methods

Earlier, we said that you want to ensure that they are unable to access this information even if they make a determined effort with all their available resources. There are methods that a malicious actor can use to get the password from the hash.

A hacker may have access to a list of your passwords from other sources, such as hacked accounts. They can use a rainbow table to try and crack the hash. If the hashes match, they have cracked the password. This may also involve brute force attacks where different combinations of passwords are tried to see if the hash of the generated password matches the hash of the original password.

While this will require high-speed resources or high computation power, these methods have been proven to be successful for older hashing algorithms, like the SHA1 family, given the computing power currently available .

So, it is immensely important to use newer hashing algorithms and other best practices to ensure that the generated hash is impossible to crack — or would take several decades of permutation/combination to crack.

Best practices for hashing

Here are some best practices for using modern hashing methods.

Salting

A salt value is a randomly generated value (usually random binary data such as random bytes of data) that is added to the password when creating a hash. Each password has its own unique salt. This provides an additional layer of security as the malicious actor would need to crack the salt along with the hash. Therefore, cracking the hash becomes a two-step process, making it more resource-intensive.

The length of the salt depends on the hashing algorithm used. In newer algorithms, the salt is generated automatically, eliminating the need to provide one. For more detailed reading, you can check out this OWASP Password Storage Cheat Sheet.

Hashing algorithms

Hashing algorithms play a crucial role in password hashing. It is essential to select a hashing algorithm that is resistant to cracking. Modern hashing algorithms allow customization of settings such as work factor and hashing iterations, making the hash more difficult to crack. Considering the computing power available, it is advisable to choose higher values. As a general rule, the higher the work factor or iteration, the more difficult it becomes to crack the hash.

As a best practice, select the highest possible value for the work factor or number of iterations that your system can handle without significant performance impact.

Considerations for hash algorithms

Here are some things to think about when implementing a hash algorithm.

Handling hash collisions

Hash collisions occur when two different inputs produce the same hash value. While modern hashing algorithms are designed to minimize the likelihood of collisions, it is still possible for them to occur. To mitigate the risk of collisions, it is recommended to use longer hash values, such as SHA-256 or SHA-512, which provide a larger output space and decrease the probability of collisions.

Iteration count

The number of iterations is a significant factor for password hashing. The higher the number of iterations, the more resource-intensive it would be to crack the hash, thus slowing down the attack. However, it's important to strike a balance between security and performance, as too many iterations may result in delays for legitimate users during the authentication process. It is recommended to use a value that provides adequate security without causing significant performance impact.

Regularly update hashing algorithms

As technology advances and computer power increases, new hashing algorithms are developed, while older hashing algorithms are deprecated. This is because the older algorithms can no longer keep up with these advances and become easy targets for attackers.

Password Hashing in Go

Go's crypto package provides built-in hashing functions for popular algorithms such as argon2, scrypt, bcrypt, pbkdf2, and more. These functions are extremely useful for implementing password hashing functionality. The bcrypt package even provides a CompareHashAndPassword function to compare plaintext passwords with hashed passwords and check for a match.

Argon2

Argon2 is currently considered the most secure hashing algorithm (although this may change in the future). It has three variants: Argon2d, which maximizes resistance to GPU cracking attacks; Argon2i, which is optimized to resist side-channel attacks; and Argon2id, which is a hybrid of both.

The OWASP Password Storage Cheat Sheet recommends using the hybrid Argon2id algorithm for password storage. The cheat sheet provides recommendations for the minimum memory size (m), the minimum number of iterations (t), and the degree of parallelism (p) as follows:

  • m=47104 (46 MiB), t=1, p=1 (Do not use with Argon2i)
  • m=19456 (19 MiB), t=2, p=1 (Do not use with Argon2i)
  • m=12288 (12 MiB), t=3, p=1
  • m=9216 (9 MiB), t=4, p=1
  • m=7168 (7 MiB), t=5, p=1

Go Implementation

Let's now examine an example implementation of Argon2id for generating a hash from a plaintext password and comparing it with the hash.

Setup

To begin, we will create a struct to store all the configuration parameters required for hashing with Argon2id:

type Argon2idHash struct {
    // time represents the number of 
    // passed over the specified memory.
    time    uint32
    // cpu memory to be used.
    memory  uint32
    // threads for parallelism aspect
    // of the algorithm.
    threads uint8
    // keyLen of the generate hash key.
    keyLen  uint32
    // saltLen the length of the salt used.
    saltLen uint32
}
Enter fullscreen mode Exit fullscreen mode

The Argon2idHash struct contains the following parameters: time, memory (computation cost), threads (parallelism), keyLen, and saltLen. These parameters are used to generate a hash from a plaintext password.

Next, we will write a constructor function to initialize the struct with the provided values.

// NewArgon2idHash constructor function for 
// Argon2idHash.
func NewArgon2idHash(time, saltLen uint32, memory uint32, threads uint8, keyLen uint32) *Argon2idHash {
    return &Argon2idHash{
        time:    time,
        saltLen: saltLen,
        memory:  memory,
        threads: threads,
        keyLen:  keyLen,
    }
}
Enter fullscreen mode Exit fullscreen mode

Generating salt

To facilitate the generation of random values, especially for salt generation, we have developed the randomSecret function. This function accepts the desired length of the random secret value and utilizes the rand.Read function to generate it.

func randomSecret(length uint32) ([]byte, error) {
    secret := make([]byte, length)

    _, err := rand.Read(secret)
    if err != nil {
        return nil, err
    }

    return secret, nil
}
Enter fullscreen mode Exit fullscreen mode

Generating Hash

Next, we create a hashing method GenerateHash on the Argon2idHash struct. This method allows us to generate a hash using the configured values. If a random salt is not provided, we generate one with the same length as the configured salt. We then use all the configured values to generate the hash using the argon2.IDKey function. If the hash is generated successfully, we return the hash and salt pair.

// GenerateHash using the password and provided salt.
// If not salt value provided fallback to random value
// generated of a given length.
func (a *Argon2idHash) GenerateHash(password, salt []byte) (*HashSalt, error) {
    var err error
    // If salt is not provided generate a salt of
    // the configured salt length.
    if len(salt) == 0 {
        salt, err = randomSecret(a.saltLen)
    }
    if err != nil {
        return nil, err
    }
    // Generate hash
    hash := argon2.IDKey(password, salt, a.time, a.memory, a.threads, a.keyLen)
    // Return the generated hash and salt used for storage.
    return &HashSalt{Hash: hash, Salt: salt}, nil
}
Enter fullscreen mode Exit fullscreen mode

Password Comparison

For password comparison, we create another method — Compare — on the Argon2idHash struct. This method takes the saved hash and salt along with the password to compare to.

First, we generate a hash using the GenerateHash function created in the previous step. This function takes the provided password and salt as inputs.

Next, we compare the generated hash with the stored hash using the bytes.Equal function. If the two hashes are equal, we have a match. If they are not equal, it means that the stored hash does not match the provided password.

// Compare generated hash with store hash.
func (a *Argon2idHash) Compare(hash, salt, password []byte) error {
    // Generate hash for comparison.
    hashSalt, err := a.GenerateHash(password, salt)
    if err != nil {
        return err
    }
    // Compare the generated hash with the stored hash.
    // If they don't match return error.
    if !bytes.Equal(hash, hashSalt.Hash) {
        return errors.New("hash doesn't match")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

In this example, we used the argon2.IDKey function function to generate the hash. As mentioned earlier, other crypto packages provided by Go have similar functions that can be used to generate secure password hashes, just like the one we generated in this example.

Scrypt

Scrypt is second in the list of hashing algorithms. Argon2id should be your first choice, but if not available scrypt should be your fallback.

Just like Argon2id, the OWASP Password Storage Cheat Sheet recommends the following values for the fields: minimum CPU/memory cost parameter (N), the blocksize (r), and the degree of parallelism (p).

  • N = 2^17 (128 MiB), r = 8 (1024 bytes), p = 1
  • N = 2^16 (64 MiB), r = 8 (1024 bytes), p = 2
  • N = 2^15 (32 MiB), r = 8 (1024 bytes), p = 3
  • N = 2^14 (16 MiB), r = 8 (1024 bytes), p = 5
  • N = 2^13 (8 MiB), r = 8 (1024 bytes), p = 10

The Go package docs for the scrypt package provide an example of how to generate a hash key using scrypt. The generation and comparison operation should be very similar to how we implement them for Argon2idHash struct.

Bcrypt

bcrypt should be your last resort or PBKDF2 (which we cover next) if FIPS-140 compliance is required. As per OWASP Password Storage Cheat Sheet the work factor should be as large as the verification server performance will allow, with a minimum of 10.

The bcrypt package in Go has hash generation and comparison function shipped. So, we are not going to walk through an example here. You can see the Go package docs for examples.

PBKDF2 (FIPS 140 compliant)

As per OWASP Password Storage Cheat Sheet, the work factor for PBKDF2 is implemented through an iteration count, which should be set differently based on the internal hashing algorithm used.

  • PBKDF2-HMAC-SHA1: 1,300,000 iterations
  • PBKDF2-HMAC-SHA256: 600,000 iterations
  • PBKDF2-HMAC-SHA512: 210,000 iterations

Hashing example

Since PBKDF2 is the only FIPS 140 compliant algorithm in our list, we are going to look at how to use it for password hashing in Go. Let’s get started.

To key function from the pbkdf2 package, that we will use to generate a hash from a plain text password is below:

func Key(password, salt []byte, iter, keyLen int, h func() hash.Hash) []byte
Enter fullscreen mode Exit fullscreen mode

To perform the hashing, you will need the password and salt as byte slices, along with the number of iterations and the desired length of the generated key or hash. A hashing function is also required. In our case, the password refers to the plaintext password that we want to hash, while the salt will be a randomly generated value. The iterations and key length can be configured. For the hashing function, we will use sha3.New256.

Setup

// PBKDF2Hash used to generate hash
// from plain text password and also
// compare plain text password with
// stored hash.
type PBKDF2Hash struct {
    // itr the number of iterations.
    itr int
    // keyLen the length of the generated key.
    keyLen int
    // saltLen the length of the salt used.
    saltLen int
}
Enter fullscreen mode Exit fullscreen mode

Generating hash

// GenerateHash using the password and provided salt.
// If not salt value provided fallback to random value
// generated of a given length.
func (p *PBKDF2Hash) GenerateHash(password, salt []byte) (*HashSalt, error) {
    var err error
    // If salt is not provided generate a salt of
    // the configured salt length.
    if len(salt) == 0 {
        salt, err = randomSecret(p.saltLen)
    }
    if err != nil {
        return nil, err
    }
    // Generate hash using pbkdf2 exported key method.
    hash := pbkdf2.Key(password, salt, p.itr, p.keyLen, sha3.New256)
    // Return the generated hash and salt used for storage.
    return &HashSalt{Hash: hash, Salt: salt}, nil
}
Enter fullscreen mode Exit fullscreen mode

Password comparison

// Compare generated hash with store hash.
func (p *PBKDF2Hash) Compare(hash, salt, password []byte) error {
    // Generate hash for comparison.
    hashSalt, err := p.GenerateHash(password, salt)
    if err != nil {
        return err
    }
    // Compare the generated hash with the stored hash.
    // If they don't match return error.
    if !bytes.Equal(hash, hashSalt.Hash) {
        return errors.New("hash doesn't match")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

This is very similar to our Argon2idHash implementation. Therefore, you can modify the full code example to allow you to use PBKDF2Hash instead.

Full code

Now let's bring it all together.

Project structure

./hashing
|_main.go
|_argon2id.go
|_go.mod
|_go.sum
Enter fullscreen mode Exit fullscreen mode

The argon2id.go file:

package main

import (
    "bytes"
    "context"
    "errors"

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

// HashSalt struct used to store
// generated hash and salt used to
// generate the hash.
type HashSalt struct {
    Hash, Salt []byte
}

type Argon2idHash struct {
    // time represents the number of 
    // passed over the specified memory.
    time    uint32
    // cpu memory to be used.
    memory  uint32
    // threads for parallelism aspect
    // of the algorithm.
    threads uint8
    // keyLen of the generate hash key.
    keyLen  uint32
    // saltLen the length of the salt used.
    saltLen uint32
}

// NewArgon2idHash constructor function for 
// Argon2idHash.
func NewArgon2idHash(time, saltLen uint32, memory uint32, threads uint8, keyLen uint32) *Argon2idHash {
    return &Argon2idHash{
        time:    time,
        saltLen: saltLen,
        memory:  memory,
        threads: threads,
        keyLen:  keyLen,
    }
}

// GenerateHash using the password and provided salt.
// If not salt value provided fallback to random value
// generated of a given length.
func (a *Argon2idHash) GenerateHash(password, salt []byte) (*HashSalt, error) {
    var err error
    // If salt is not provided generate a salt of
    // the configured salt length.
    if len(salt) == 0 {
        salt, err = randomSecret(a.saltLen)
    }
    if err != nil {
        return nil, err
    }
    // Generate hash
    hash := argon2.IDKey(password, salt, a.time, a.memory, a.threads, a.keyLen)
    // Return the generated hash and salt used for storage.
    return &HashSalt{Hash: hash, Salt: salt}, nil
}

// Compare generated hash with store hash.
func (a *Argon2idHash) Compare(hash, salt, password []byte) error {
    // Generate hash for comparison.
    hashSalt, err := a.GenerateHash(password, salt)
    if err != nil {
        return err
    }
    // Compare the generated hash with the stored hash.
    // If they don't match return error.
    if !bytes.Equal(hash, hashSalt.Hash) {
        return errors.New("hash doesn't match")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The main.go file:

package main

import (
    "context"
    "crypto/rand"

    "fmt"
    "os"
)

func main() {
    password := []byte("super-secret-password")

    argon2IDHash := NewArgon2idHash(1, 32, 64*1024, 32, 256)

    hashSalt, err := argon2IDHash.GenerateHash(password, nil)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    fmt.Println(hashSalt.Hash)
    fmt.Println(hashSalt.Salt)

    err = argon2IDHash.Compare(hashSalt.Hash, hashSalt.Salt, password)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println("argon2IDHash Password and Hash match")
}

func randomSecret(length uint32) ([]byte, error) {
    secret := make([]byte, length)

    _, err := rand.Read(secret)
    if err != nil {
        return nil, err
    }

    return secret, nil
}
Enter fullscreen mode Exit fullscreen mode

Choose your algorithm wisely

The examples above give you a great introduction to implementing secure hashing algorithms for password storage in Go. Remember that not all hashing algorithms are equal, and you should choose them wisely based on your needs and the current security state of the algorithm. What is considered a strong algorithm today will change over time, so it is wise to reassess this periodically.

Also, know that SAST tools like Snyk Code can help you identify outdated algorithms and advise you on how to update if needed. Choose your password-hashing algorithm wisely, stay informed, and happy coding!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .