1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
|
package player
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters for password hashing. Memory cost dominates the
// GPU/ASIC attack economics; 64 MiB per hash is affordable for a MUD's
// login rate while making brute force expensive. Stored format:
//
// argon2id:<time>:<memoryKiB>:<threads>:<b64salt>:<hexkey>
//
// Legacy accounts may still carry the old single-pass salted SHA-256 hash
// ("sha256:<b64salt>:<hexhash>"); CheckPassword verifies both, and the
// login paths rehash to Argon2id on successful verification (see
// NeedsRehash).
const (
argonTime = 1
argonMemory = 64 * 1024 // KiB
argonThreads = 2
argonKeyLen = 32
argonSaltLen = 16
)
// Sanity bounds applied to Argon2id parameters parsed from a stored hash.
// HashPassword always writes the constants above, but a hand-edited or
// corrupted account file can carry arbitrary numbers; without these bounds a
// value like 4294967295 (or a negative that wraps through uint32) makes
// argon2.IDKey request a multi-TiB allocation and kills the process instead of
// failing the login. Anything outside these ranges is rejected before hashing.
const (
argonMinTime = 1
argonMaxTime = 8
argonMinMemory = 8 * 1024 // KiB (8 MiB)
argonMaxMemory = 256 * 1024 // KiB (256 MiB)
argonMinThreads = 1
argonMaxThreads = 16
argonMinKeyLen = 16
argonMaxKeyLen = 64
)
// hashLimiter bounds concurrent Argon2id computations. Each hash costs
// ~64 MiB and tens of milliseconds, so an unbounded burst of logins (or
// login attempts) would spike memory and CPU in parallel. Password work
// runs on connection/HTTP goroutines (never the game owner); this only
// caps how many run at once. Callers acquire per computation, never
// nested, so the limited channel cannot deadlock.
var hashLimiter = make(chan struct{}, 4)
func acquireHashSlot() {
hashLimiter <- struct{}{}
}
func releaseHashSlot() {
<-hashLimiter
}
// HashPassword derives an Argon2id password hash.
func HashPassword(password string) (string, error) {
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
acquireHashSlot()
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
releaseHashSlot()
return fmt.Sprintf("argon2id:%d:%d:%d:%s:%s",
argonTime, argonMemory, argonThreads,
base64.RawStdEncoding.EncodeToString(salt), hex.EncodeToString(key)), nil
}
// CheckPassword verifies a password against a stored hash in either the
// current Argon2id format or the legacy salted SHA-256 format.
func CheckPassword(password, stored string) bool {
parts := strings.SplitN(stored, ":", 6)
if len(parts) == 0 {
return false
}
switch parts[0] {
case "argon2id":
if len(parts) != 6 {
return false
}
t, errT := strconv.Atoi(parts[1])
m, errM := strconv.Atoi(parts[2])
p, errP := strconv.Atoi(parts[3])
salt, errSalt := base64.RawStdEncoding.DecodeString(parts[4])
expected, errKey := hex.DecodeString(parts[5])
if errT != nil || errM != nil || errP != nil || errSalt != nil || errKey != nil {
return false
}
if t < argonMinTime || t > argonMaxTime ||
m < argonMinMemory || m > argonMaxMemory ||
p < argonMinThreads || p > argonMaxThreads ||
len(expected) < argonMinKeyLen || len(expected) > argonMaxKeyLen {
// Out-of-range parameters: fail the check instead of trusting the
// stored values and requesting an enormous allocation.
return false
}
acquireHashSlot()
key := argon2.IDKey([]byte(password), salt, uint32(t), uint32(m), uint8(p), uint32(len(expected)))
releaseHashSlot()
return subtle.ConstantTimeCompare(key, expected) == 1
case "sha256":
legacy := strings.SplitN(stored, ":", 3)
if len(legacy) != 3 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(legacy[1])
if err != nil {
return false
}
hash := sha256.Sum256(append(salt, []byte(password)...))
return legacy[2] == fmt.Sprintf("%x", hash)
default:
return false
}
}
// NeedsRehash reports whether a stored hash uses the legacy single-pass
// SHA-256 scheme. Callers rehash with HashPassword after a successful
// verification and persist the new hash (transparent upgrade on login).
func NeedsRehash(stored string) bool {
return strings.HasPrefix(stored, "sha256:")
}
|