aboutsummaryrefslogtreecommitdiff
path: root/internal/player/password.go
blob: 9be39fb7a5a855483a054728890f3bd2ba54d0a8 (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
108
109
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
)

// 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
		}
		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:")
}