感谢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选择正确的参数在很大程度上取决于算法运行的机器,你可能需要做一些实验,以便适当地设置它们。
- 将平行度和内存参数设置为你愿意承担的最大数量,记住你可能不想将这些参数完全调到最大,除非你的机器是专门用于密码散列。
- 增加迭代次数,直到达到你的最大运行时间限制(例如,500ms)。
- 如果你在迭代次数=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 := ¶ms{
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
验证密码
最后要介绍的是如何验证密码。
在大多数情况下,你会把我们刚刚产生的编码密码哈希值储存在某种数据库中。然后在以后,你要检查用户提供的明文密码是否与编码后的密码哈希值一致。
从本质上讲,进行这种检查的步骤是:
- 从存储在数据库中的编码密码哈希值中提取盐和参数。
- 使用完全相同的Argon2变体、版本、盐和参数推导出明文密码的哈希值。
- 检查这个新的哈希值是否与原始哈希值相同。
你可以这样实现:
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 := ¶ms{
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 = ¶ms{}
_, 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 := ¶ms{
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