aboutsummaryrefslogtreecommitdiff
path: root/internal/player/password.go
blob: 706ef454a1c13c121ad821cdedf2f4a4915a26b4 (plain)
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
package player

import (
	"crypto/rand"
	"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>
//
// Only Argon2id is accepted: the prototype has no password-hash migration and
// accounts are recreated rather than upgraded.
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 Argon2id hash.
func CheckPassword(password, stored string) bool {
	parts := strings.SplitN(stored, ":", 6)
	if len(parts) == 0 || parts[0] != "argon2id" {
		return false
	}
	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
}