aboutsummaryrefslogtreecommitdiff
path: root/internal/player
diff options
context:
space:
mode:
Diffstat (limited to 'internal/player')
-rw-r--r--internal/player/password.go77
-rw-r--r--internal/player/password_test.go60
2 files changed, 127 insertions, 10 deletions
diff --git a/internal/player/password.go b/internal/player/password.go
index 17e4218..f8ce3cc 100644
--- a/internal/player/password.go
+++ b/internal/player/password.go
@@ -3,30 +3,87 @@ 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
)
+// HashPassword derives an Argon2id password hash.
func HashPassword(password string) (string, error) {
- salt := make([]byte, 16)
+ salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
- hash := sha256.Sum256(append(salt, []byte(password)...))
- return fmt.Sprintf("sha256:%s:%x", base64.RawStdEncoding.EncodeToString(salt), hash), nil
+ key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
+ 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, ":", 3)
- if len(parts) != 3 || parts[0] != "sha256" {
+ parts := strings.SplitN(stored, ":", 6)
+ if len(parts) == 0 {
return false
}
- salt, err := base64.RawStdEncoding.DecodeString(parts[1])
- if err != nil {
+ 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
+ }
+ key := argon2.IDKey([]byte(password), salt, uint32(t), uint32(m), uint8(p), uint32(len(expected)))
+ 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
}
- hash := sha256.Sum256(append(salt, []byte(password)...))
- expected := fmt.Sprintf("%x", hash)
- return parts[2] == expected
+}
+
+// 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:")
}
diff --git a/internal/player/password_test.go b/internal/player/password_test.go
new file mode 100644
index 0000000..82b580b
--- /dev/null
+++ b/internal/player/password_test.go
@@ -0,0 +1,60 @@
+package player
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func TestHashPasswordUsesArgon2idAndVerifies(t *testing.T) {
+ hash, err := HashPassword("secret password")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(hash, "argon2id:") {
+ t.Fatalf("hash format = %q, want argon2id", hash)
+ }
+ if !CheckPassword("secret password", hash) {
+ t.Fatal("correct password rejected")
+ }
+ if CheckPassword("wrong password", hash) {
+ t.Fatal("wrong password accepted")
+ }
+ // Salts must be unique per call: two hashes of the same password differ.
+ other, _ := HashPassword("secret password")
+ if other == hash {
+ t.Fatal("two hashes of the same password are identical (salt reuse)")
+ }
+}
+
+func TestCheckPasswordVerifiesLegacySHA256(t *testing.T) {
+ salt := make([]byte, 16)
+ for i := range salt {
+ salt[i] = byte(i)
+ }
+ sum := sha256.Sum256(append(salt, []byte("legacy pw")...))
+ legacy := fmt.Sprintf("sha256:%s:%x", base64.RawStdEncoding.EncodeToString(salt), sum)
+
+ if !CheckPassword("legacy pw", legacy) {
+ t.Fatal("correct legacy password rejected")
+ }
+ if CheckPassword("wrong", legacy) {
+ t.Fatal("wrong legacy password accepted")
+ }
+ if !NeedsRehash(legacy) {
+ t.Fatal("legacy hash not flagged for rehash")
+ }
+ if NeedsRehash("argon2id:1:65536:2:c2FsdA:x") {
+ t.Fatal("argon2id hash flagged for rehash")
+ }
+}
+
+func TestCheckPasswordRejectsGarbage(t *testing.T) {
+ for _, stored := range []string{"", "plain", "argon2id:1:2:3:no-salt:zz", "unknown:v1:salt:deadbeef"} {
+ if CheckPassword("anything", stored) {
+ t.Fatalf("garbage hash accepted: %q", stored)
+ }
+ }
+}