如何在Go中用Argon2对密码进行散列和验证(详细教程)

1,340 阅读6分钟

感谢golang.org/x/crypto/ar…软件包的作者Andreas Auernhammer在发表前检查了这篇文章。

如果你打算存储用户密码,那么使用Bcrypt、Scrypt或Argon2等计算成本较高的密钥衍生函数(KDF)对其进行散列是很好的做法(真的很重要)。

由于golang.org/x/crypto/bc…包和Matt Silverlock的elithrar/simple-scrypt包,在Go中使用Bcrypt和Scrypt进行哈希和验证密码已经很容易了。我推荐这两个包。

如果你想使用Argon2--它被广泛认为是同类KDF中最好的散列密码--那么你有几个选择。

tvdburgt/go-argon2包为libargon2 C库提供了Go绑定,或者你可以通过将golang.org/x/crypto/ar…包与用于哈希和验证密码的辅助程序进行包装,实现一个纯粹的Go解决方案。在这篇文章的其余部分,我将确切地解释如何使用这种纯Go方法。

Argon2的简单介绍

但首先,要介绍一下背景:

必须说明的是,Argon2算法有3个变体,其工作方式略有不同。Argon2d、Argon2i和Argon2id。一般来说,对于密码散列,你应该使用Argon2id变体。这基本上是Argon2d和Argon2i算法的混合体,并使用了与数据无关的内存访问(用于抵抗侧信道定时攻击)和依赖于数据的内存访问(用于抵抗GPU破解攻击)的组合。

Argon2算法接受一些可配置的参数:

  • 内存 - 算法使用的内存量(单位:千字节)。
  • 迭代--在内存上的迭代(或传递)次数。
  • Parallelism - 算法使用的线程(或通道)的数量。
  • Salt length - 随机盐的长度。建议密码散列使用16字节
  • Key length - 生成的密钥(或密码散列)的长度。建议16字节或更多。

内存迭代参数控制密码散列的计算成本。这些数字越高,生成哈希值的成本就越大。因此,任何试图猜测密码的攻击者的成本也会越高。

但是,你需要取得一个平衡。当你增加成本时,生成哈希值的时间也会增加。如果你是根据用户的操作(如注册或登录网站)来生成哈希值,那么你可能希望将运行时间控制在500ms以内,以避免产生负面的用户体验。

如果Argon2算法是在有多个内核的机器上运行,那么减少运行时间而不减少成本的一个方法是增加并行参数。这可以控制工作分散到的线程数量。但有一件重要的事情要注意:改变并行性参数的值会改变算法的输出。所以--例如--以2的平行度参数运行Argon2和以4的平行度参数运行Argon2会产生不同的密码散列。

选择参数

为Argon2选择正确的参数在很大程度上取决于算法运行的机器,你可能需要做一些实验,以便适当地设置它们。

建议 选择参数的过程 可以解析如下:

  1. 将平行度和内存参数设置为你愿意承担的最大数量,记住你可能不想将这些参数完全调到最大,除非你的机器是专门用于密码散列。
  2. 增加迭代次数,直到达到你的最大运行时间限制(例如,500ms)。
  3. 如果你在迭代次数=1的情况下已经超过了你的最大运行时间限制,那么你应该减少内存参数。

哈希密码

现在这些解释已经结束了,让我们开始写代码,用Argon2哈希密码。

首先,你需要go get golang.org/x/crypto/ar…包,它实现了Argon2算法:

$ go get golang.org/x/crypto/argon2

而你可以像这样用它来哈希一个特定的密码:

File: main.go
package main

import (
    "crypto/rand"
    "fmt"
    "log"

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

type params struct {
    memory      uint32
    iterations  uint32
    parallelism uint8
    saltLength  uint32
    keyLength   uint32
}

func main() {
    // Establish the parameters to use for Argon2.
    p := &params{
        memory:      64 * 1024,
        iterations:  3,
        parallelism: 2,
        saltLength:  16,
        keyLength:   32,
    }

    // Pass the plaintext password and parameters to our generateFromPassword
    // helper function.
    hash, err := generateFromPassword("password123", p)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(hash)
}

func generateFromPassword(password string, p *params) (hash []byte, err error) {
    // Generate a cryptographically secure random salt.
    salt, err := generateRandomBytes(p.saltLength)
    if err != nil {
        return nil, err
    }

    // Pass the plaintext password, salt and parameters to the argon2.IDKey
    // function. This will generate a hash of the password using the Argon2id
    // variant.
    hash = argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength)

    return hash, nil
}

func generateRandomBytes(n uint32) ([]byte, error) {
    b := make([]byte, n)
    _, err := rand.Read(b)
    if err != nil {
        return nil, err
    }

    return b, nil
}

关于术语和命名的简单说明。从形式上看,Argon2是一个密钥衍生函数,它产生一个由所提供的密码和盐衍生的密钥。这个衍生的密钥就是我们的 "散列密码"。

这里要指出的另一件重要事情是generateRandomBytes() 函数。在这里我们使用Go的crypto/rand包来生成一个加密安全的随机盐,而不是使用固定盐或伪随机盐。

如果你在这时运行程序,它应该打印出一个包含哈希密码字节的片断,与此相似。

``` $ go run main.go [9 18 35 54 101 221 120 189 57 241 229 248 140 1 102 58 93 211 115 49 131 162 24 50 167 142 227 198 85 186 200 248] ```

每次你运行程序时,你会看到,由于加入了我们的随机盐,它对同一个密码的输出结果完全不同。

储存密码

所以,用一些特定的参数创建一个散列的密码是很简单的。但在大多数情况下,你想把你使用的盐和特定参数哈希密码一起存储起来,这样就可以在以后的某个时候对它进行可重复的验证。

做到这一点的标准方法是创建一个哈希密码的编码表示,看起来像这样:

$ go run main.go
[9 18 35 54 101 221 120 189 57 241 229 248 140 1 102 58 93 211 115 49 131 162 24 50 167 142 227 198 85 186 200 248]

让我们来分析一下这代表什么:

  • $argon2id - 正在使用的Argon2的变体。
  • $v=19 - 正在使用的Argon2的版本。
  • $m=65536,t=3,p=2 - 正在使用的内存( )、迭代( )和并行( )参数。m``t``p
  • $c29tZXNhbHQ - base64编码的盐,使用标准的base64编码,没有填充。
  • $c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG - base64编码的哈希密码(派生密钥),使用标准的base64编码和无填充。

让我们更新generateHash() 函数,使其返回这种格式的字符串。

File: main.go
package main

import (
    "crypto/rand"
    "encoding/base64"
    "fmt"
    "log"

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

...

func generateFromPassword(password string, p *params) (encodedHash string, err error) {
    salt, err := generateRandomBytes(p.saltLength)
    if err != nil {
        return "", err
    }

    hash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength)

    // Base64 encode the salt and hashed password.
    b64Salt := base64.RawStdEncoding.EncodeToString(salt)
    b64Hash := base64.RawStdEncoding.EncodeToString(hash)

    // Return a string using the standard encoded hash representation.
    encodedHash = fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, p.memory, p.iterations, p.parallelism, b64Salt, b64Hash)

    return encodedHash, nil
}

...

如果你现在再次运行这段代码,输出结果应该与此类似:

$ go run run.go
$argon2id$v=19$m=65536,t=3,p=2$Woo1mErn1s7AHf96ewQ8Uw$D4TzIwGO4XD2buk96qAP+Ed2baMo/KbTRMqXX00wtsU

验证密码

最后要介绍的是如何验证密码。

在大多数情况下,你会把我们刚刚产生的编码密码哈希值储存在某种数据库中。然后在以后,你要检查用户提供的明文密码是否与编码后的密码哈希值一致

从本质上讲,进行这种检查的步骤是:

  1. 从存储在数据库中的编码密码哈希值中提取盐和参数。
  2. 使用完全相同的Argon2变体、版本、盐和参数推导出明文密码的哈希值。
  3. 检查这个新的哈希值是否与原始哈希值相同。

你可以这样实现:

File: main.go
package main

import (
    "crypto/rand"
    "crypto/subtle"
    "encoding/base64"
    "errors"
    "fmt"
    "log"
    "strings"

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

var (
    ErrInvalidHash         = errors.New("the encoded hash is not in the correct format")
    ErrIncompatibleVersion = errors.New("incompatible version of argon2")
)

type params struct {
    memory      uint32
    iterations  uint32
    parallelism uint8
    saltLength  uint32
    keyLength   uint32
}

func main() {
    p := &params{
        memory:      64 * 1024,
        iterations:  3,
        parallelism: 2,
        saltLength:  16,
        keyLength:   32,
    }

    encodedHash, err := generateFromPassword("password123", p)
    if err != nil {
        log.Fatal(err)
    }

    match, err := comparePasswordAndHash("password123", encodedHash)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("Match: %v\n", match)
}

...

func comparePasswordAndHash(password, encodedHash string) (match bool, err error) {
    // Extract the parameters, salt and derived key from the encoded password
    // hash.
    p, salt, hash, err := decodeHash(encodedHash)
    if err != nil {
        return false, err
    }

    // Derive the key from the other password using the same parameters.
    otherHash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength)

    // Check that the contents of the hashed passwords are identical. Note
    // that we are using the subtle.ConstantTimeCompare() function for this
    // to help prevent timing attacks.
    if subtle.ConstantTimeCompare(hash, otherHash) == 1 {
        return true, nil
    }
    return false, nil
}

func decodeHash(encodedHash string) (p *params, salt, hash []byte, err error) {
    vals := strings.Split(encodedHash, "$")
    if len(vals) != 6 {
        return nil, nil, nil, ErrInvalidHash
    }

    var version int
    _, err = fmt.Sscanf(vals[2], "v=%d", &version)
    if err != nil {
        return nil, nil, nil, err
    }
    if version != argon2.Version {
        return nil, nil, nil, ErrIncompatibleVersion
    }

    p = &params{}
    _, err = fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", &p.memory, &p.iterations, &p.parallelism)
    if err != nil {
        return nil, nil, nil, err
    }

    salt, err = base64.RawStdEncoding.Strict().DecodeString(vals[4])
    if err != nil {
        return nil, nil, nil, err
    }
    p.saltLength = uint32(len(salt))

    hash, err = base64.RawStdEncoding.Strict().DecodeString(vals[5])
    if err != nil {
        return nil, nil, nil, err
    }
    p.keyLength = uint32(len(hash))

    return p, salt, hash, nil
}

如果你现在运行这段代码,在比较明文和哈希密码时,你应该得到一个肯定的匹配,并看到这样的输出:

$ go run main.go
Match: true

如果你改变其中一个函数调用中使用的明文密码,像这样。

File: main.go
package main

...

func main() {
    p := &params{
        memory:      64 * 1024,
        iterations:  3,
        parallelism: 2,
        saltLength:  16,
        keyLength:   32,
    }

    encodedHash, err := generateFromPassword("password123", p)
    if err != nil {
        log.Fatal(err)
    }

    // Use a different password...
    match, err := comparePasswordAndHash("pa$$word", encodedHash)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("Match: %v\n", match)
}

...

那么运行该代码应该会出现一个负的匹配结果:

$ go run main.go
Match: false