diff options
| author | historia <[not public]> | 2026-09-15 19:55:21 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 19:55:21 -0400 |
| commit | 6f3c43aca62a7d33a19f14f6ad7c16891952673a (patch) | |
| tree | 0b0665ef624ef442df87ec7131129aaecc3420bd /internal/player | |
| parent | cd95de60364aba8f5bdf8ec86f7234a9903eacc8 (diff) | |
| download | thehouseoficarus-6f3c43aca62a7d33a19f14f6ad7c16891952673a.tar.gz | |
fix: outstanding-stake ledger, owner backpressure, editor state preservation
Diffstat (limited to 'internal/player')
| -rw-r--r-- | internal/player/password.go | 77 | ||||
| -rw-r--r-- | internal/player/password_test.go | 60 |
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) + } + } +} |
