password.go
⎇
Raw
1package db
2
3import (
4 "crypto/rand"
5 "crypto/subtle"
6 "encoding/base64"
7 "errors"
8 "fmt"
9 "strings"
10 "sync"
11
12 "golang.org/x/crypto/argon2"
13)
14
15// Parameters of the `argon2` npm package, which produced every hash already
16// stored in the database. Keep them identical so old hashes still verify.
17const (
18 argonTime = 3
19 argonMemory = 64 * 1024
20 argonThreads = 4
21 argonKeyLen = 32
22 argonSaltLen = 16
23)
24
25var errBadHash = errors.New("invalid argon2 hash")
26
27// argonSlots caps parallel argon2 runs. Each run allocates 64 MiB, so a
28// burst of logins could otherwise exhaust memory.
29var argonSlots = make(chan struct{}, 2)
30
31func idKey(pw, salt []byte, time, memory uint32, threads uint8, keyLen uint32) []byte {
32 argonSlots <- struct{}{}
33 defer func() { <-argonSlots }()
34 return argon2.IDKey(pw, salt, time, memory, threads, keyLen)
35}
36
37// HashPassword returns a PHC-format argon2id hash string.
38func HashPassword(pw string) (string, error) {
39 salt := make([]byte, argonSaltLen)
40 if _, err := rand.Read(salt); err != nil {
41 return "", err
42 }
43 key := idKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
44 b64 := base64.RawStdEncoding
45 return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
46 argon2.Version, argonMemory, argonTime, argonThreads,
47 b64.EncodeToString(salt), b64.EncodeToString(key)), nil
48}
49
50// dummyHash is a real argon2 hash of a fixed string, computed once.
51var dummyHash = sync.OnceValue(func() string {
52 h, err := HashPassword("hearthforge-unknown-user")
53 if err != nil {
54 return ""
55 }
56 return h
57})
58
59// VerifyDummyPassword does the same argon2 work as VerifyPassword and always
60// fails. Call it when the account does not exist, so a login for an unknown
61// username takes about as long as one for a known one.
62func VerifyDummyPassword(pw string) {
63 _, _ = VerifyPassword(dummyHash(), pw)
64}
65
66// VerifyPassword checks a password against a stored PHC hash string.
67func VerifyPassword(hash, pw string) (bool, error) {
68 parts := strings.Split(hash, "$")
69 if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" {
70 return false, errBadHash
71 }
72 var version int
73 if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
74 return false, errBadHash
75 }
76 if version != argon2.Version {
77 return false, fmt.Errorf("%w: unsupported version %d", errBadHash, version)
78 }
79 var memory, time uint32
80 var threads uint8
81 if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
82 return false, errBadHash
83 }
84 b64 := base64.RawStdEncoding
85 salt, err := b64.DecodeString(parts[4])
86 if err != nil {
87 return false, errBadHash
88 }
89 want, err := b64.DecodeString(parts[5])
90 if err != nil {
91 return false, errBadHash
92 }
93 got := idKey([]byte(pw), salt, time, memory, threads, uint32(len(want)))
94 return subtle.ConstantTimeCompare(got, want) == 1, nil
95}
96